From cdf1bbc0d6ff4f5978c361aa4a8247c256173d61 Mon Sep 17 00:00:00 2001 From: foppe Date: Fri, 20 Feb 2026 21:48:20 +0100 Subject: [PATCH] feat: add PaymentController v2 architecture Add a template-method based PaymentController that orchestrates the full payment lifecycle: initiate -> proceed -> process_response. - PaymentController base class with abstract gateway contracts - Payment Session Log DocType for tracking payment state - Payment Button DocType for gateway selection UI - /pay universal payment endpoint with multi-gateway support - Type system (TxData, Proceeded, Processed, SessionStates) - Custom exception hierarchy for structured error handling - TX data filtering whitelist to prevent parameter tampering - Concurrency guards with document locking and terminal state checks - is_v2_gateway() utility for detecting v2-compatible gateways - Fix delete_custom_fields to use composite key lookup - Fix get_checkout_url to route through get_payment_gateway_controller - Add Payment Session Log link field on Payment Request - Integration tests for controller lifecycle --- payments/controllers/__init__.py | 1 + payments/controllers/payment_controller.py | 654 ++++++++++++++++++ .../controllers/test_payment_controller.py | 305 ++++++++ payments/exceptions.py | 23 + payments/hooks.py | 2 + payments/payments/doctype/__init__.py | 0 .../doctype/payment_button/__init__.py | 0 .../doctype/payment_button/payment_button.js | 21 + .../payment_button/payment_button.json | 161 +++++ .../doctype/payment_button/payment_button.py | 76 ++ .../payment_button/test_payment_button.py | 29 + .../doctype/payment_session_log/__init__.py | 0 .../payment_session_log.js | 6 + .../payment_session_log.json | 141 ++++ .../payment_session_log.py | 249 +++++++ .../payment_session_log_list.js | 22 + .../test_payment_session_log.py | 176 +++++ payments/types.py | 187 +++++ payments/utils/__init__.py | 5 + payments/utils/utils.py | 130 +++- payments/www/__init__.py | 0 payments/www/pay.css | 8 + payments/www/pay.html | 168 +++++ payments/www/pay.js | 72 ++ payments/www/pay.py | 117 ++++ 25 files changed, 2529 insertions(+), 24 deletions(-) create mode 100644 payments/controllers/__init__.py create mode 100644 payments/controllers/payment_controller.py create mode 100644 payments/controllers/test_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..4d63e8b9 --- /dev/null +++ b/payments/controllers/payment_controller.py @@ -0,0 +1,654 @@ +from __future__ import annotations + +import json +from typing import TYPE_CHECKING, ClassVar +from urllib.parse import quote, urlencode + +import frappe +from frappe import _ +from frappe.desk.form.load import get_document_email +from frappe.email.doctype.email_account.email_account import EmailAccount +from frappe.model.base_document import get_controller +from frappe.model.document import Document +from frappe.utils import get_url +from requests.exceptions import HTTPError + +from payments.exceptions import ( + FailedToInitiateFlowError, + PayloadIntegrityError, + PaymentControllerProcessingError, + RefDocHookProcessingError, +) +from payments.payments.doctype.payment_session_log.payment_session_log import ( + PaymentSessionLog, + create_log, +) +from payments.types import ( + ActionAfterProcessed, + FrontendDefaults, + GatewayProcessingResponse, + Initiated, + PaymentUrl, + Proceeded, + Processed, + PSLName, + RemoteServerInitiationPayload, + SessionStates, + SessionType, + TxData, + _Processed, +) +from payments.utils import PAYMENT_SESSION_REF_KEY + +if TYPE_CHECKING: + from payments.payments.doctype.payment_gateway.payment_gateway import PaymentGateway + + +def _error_value(error, flow): + return _( + "Our server had a problem processing your {0}. Please contact customer support mentioning: {1}" + ).format(flow, error) + + +class PaymentController(Document): + """This controller implements the public API of payment gateway controllers.""" + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + frontend_defaults: FrontendDefaults + flowstates: SessionStates + + # Fields that can be updated at proceed() time. + # Critical fields (amount, currency, reference_doctype, reference_docname) are NOT allowed + # to prevent tampering via the guest-facing /pay endpoint. + UPDATABLE_TX_DATA_FIELDS = frozenset( + { + "payer_contact", + "payer_address", + "loyalty_points", + "discount_amount", + } + ) + + @staticmethod + def _filter_tx_data_updates(updates: dict | None) -> dict: + """Filter updated_tx_data to only allow whitelisted fields. + + This prevents tampering with critical fields like amount, currency, + and reference document through the proceed() endpoint. + + Args: + updates: The raw updates dict from the caller + + Returns: + Filtered dict containing only allowed fields + """ + if not updates: + return {} + + filtered = {} + rejected = [] + + for key, value in updates.items(): + if key in PaymentController.UPDATABLE_TX_DATA_FIELDS: + filtered[key] = value + else: + rejected.append(key) + + if rejected: + frappe.logger("payments").warning( + f"Rejected tx_data update for non-whitelisted fields: {rejected}" + ) + + return filtered + + def __new__(cls, *args, **kwargs): + if not (hasattr(cls, "flowstates") and isinstance(cls.flowstates, SessionStates)): + raise TypeError( + f"{cls.__name__} must declare cls.flowstates as an instance of payments.types.SessionStates" + ) + if not (hasattr(cls, "frontend_defaults") and isinstance(cls.frontend_defaults, FrontendDefaults)): + raise TypeError( + f"{cls.__name__} must declare cls.frontend_defaults as an instance of payments.types.FrontendDefaults" + ) + return super().__new__(cls) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.state = frappe._dict() + + @staticmethod + def initiate( + tx_data: TxData, + gateway: PaymentController | None = None, + correlation_id: str | None = None, + name: str | None = None, + ) -> tuple[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 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_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 + 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. Only whitelisted fields can be updated (see + UPDATABLE_TX_DATA_FIELDS). Critical fields like amount, currency, and reference + document cannot be changed to prevent tampering. + + 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_doc("Payment Session Log", psl_name) + self: PaymentController = psl.get_controller() + + # Idempotency: if already initiated, return existing payload instead of re-initiating + # This prevents duplicate external API calls on page refresh + if psl.status == "Initiated" and psl.initiation_response_payload: + self.state = psl.load_state() + self.state.tx_data = self._patch_tx_data(self.state.tx_data) + payload = json.loads(psl.initiation_response_payload) + return Proceeded( + integration=self.doctype, + psltype=psl.flow_type or SessionType.charge, + txdata=self.state.tx_data, + payload=payload, + ) + + # Filter updates to only allow whitelisted fields (security: prevents tampering) + filtered_updates = PaymentController._filter_tx_data_updates(updated_tx_data) + psl.update_tx_data(filtered_updates, "Started") # commits + + self.state = psl.load_state() + # controller specific temporary modifications + self.state.tx_data = self._patch_tx_data(self.state.tx_data) + + try: + frappe.flags.integration_request_doc = psl # for linking error logs + + 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, + txdata=self.state.tx_data, + payload=initiated.payload, + ) + + # some gateways don't return HTTP errors ... + except FailedToInitiateFlowError as err: + psl.set_initiation_payload(err.data, "Error") + error = psl.log_error(title=err.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: + 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} and {1}").format(psl, error), + http_status_code=401, + indicator_color="yellow", + ) + raise frappe.Redirect + + except Exception: + 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 _get_support_email(self): + """Look up the support email for the reference document, falling back to default incoming.""" + incoming = get_document_email( + self.state.tx_data.reference_doctype, + self.state.tx_data.reference_docname, + ) + if not incoming: + account = EmailAccount.find_default_incoming() + incoming = account.email_id if account else None + return incoming + + def _build_support_action(self, psl, subject, body, fallback_action): + """Build a mailto action for user support, falling back to fallback_action if no email configured.""" + incoming_email = self._get_support_email() + if incoming_email: + params = { + "subject": subject, + "body": body, + } + href = f"mailto:{incoming_email}?{urlencode(params, quote_via=quote)}" + return dict(href=href, label=_("Email Us")) + return fallback_action + + # Status category → (psl_status, indicator_color, message_template, action_label) + # Note: action labels are raw strings; wrapped in _() at render time to support i18n. + # Translation markers for extraction: _("Go to Homepage"), _("Refresh") + # Message markers: _("{} succeeded"), _("{} authorized"), _("{} awaiting further processing by the bank") + _STATUS_MAP: ClassVar[dict] = { + "success": ("Paid", "green", "{} succeeded", dict(href="/", label="Go to Homepage")), + "pre_authorized": ("Authorized", "green", "{} authorized", dict(href="/", label="Go to Homepage")), + "processing": ( + "Processing", + "yellow", + "{} awaiting further processing by the bank", + dict(href="/", label="Refresh"), + ), + } + + def _process_response( + self, psl: PaymentSessionLog, response: GatewayProcessingResponse, ref_doc: Document + ) -> Processed: + self._validate_response() + + processed = None + try: + processed = self._process_response_for_charge() # idempotent on second run + except Exception as e: + raise PaymentControllerProcessingError( + f"{self._process_response_for_charge} failed", "charge" + ) from e + + all_states = ( + self.flowstates.success + + self.flowstates.pre_authorized + + self.flowstates.processing + + self.flowstates.declined + ) + if self.flags.status_changed_to not in all_states: + raise ValueError( + "self.flags.status_changed_to must be in the set of possible states for this controller:\n - {}".format( + "\n - ".join(all_states) + ) + ) + + ret = { + "status_changed_to": self.flags.status_changed_to, + "payload": response.payload, + } + + changed = False + + # Handle success / pre_authorized / processing (common structure) + for category, (psl_status, color, msg_template, action_label) in self._STATUS_MAP.items(): + if self.flags.status_changed_to in getattr(self.flowstates, category): + changed = psl_status != psl.status + psl.db_set("decline_reason", None) + psl.set_processing_payload(response, psl_status) # commits + ret["indicator_color"] = color + processed = processed or Processed( + message=_(msg_template).format("charge".title()), + action=dict(action_label, label=_(action_label["label"])), + **ret, + ) + break + + # Handle declined (structurally different: resets button, builds support mailto) + if self.flags.status_changed_to in self.flowstates.declined: + changed = "Declined" != psl.status + 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" + + action = self._build_support_action( + psl, + subject=_("Help! Payment declined: {}, {}").format( + self.state.tx_data.reference_docname, psl.name + ), + # nosemgrep: frappe-translation-python-splitting - newlines in email body are intentional + 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 + ), + ), + fallback_action=dict(href=PaymentController.get_payment_url(psl.name), label=_("Refresh")), + ) + processed = processed or Processed( + message=_("{} declined").format("charge".title()), + action=action, + **ret, + ) + + # Check if ref_doc implements the hook method (optional for smoother adoption) + hookmethod = "on_payment_charge_processed" + has_hook = hasattr(ref_doc, hookmethod) and callable(getattr(ref_doc, hookmethod, None)) + + if has_hook: + try: + ref_doc.flags.payment_session = frappe._dict( + 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, + ) + # 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 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("RefDoc hook processing failed", "charge") from e + + return processed + + @staticmethod + def process_response(psl_name: PSLName, response: GatewayProcessingResponse) -> Processed: + """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 + + 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_doc("Payment Session Log", psl_name) + self: PaymentController = psl.get_controller() + + # Guard against concurrent processing (e.g. webhook + client confirm race) + psl.lock(timeout=5) + psl.reload() + + # After acquiring the lock, check if another process already handled this + if psl.is_terminal(): + psl.unlock() + return Processed( + message=_(psl.status), + action=dict(href="/", label=_("Go to Homepage")), + status_changed_to=psl.status, + indicator_color=psl.get_indicator_color(), + payload={}, + ) + + 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() + + def get_compensatory_action(error_log): + return self._build_support_action( + psl, + subject=_("Payment Server Error: {}").format(error_log), + # nosemgrep: frappe-translation-python-splitting - newlines in email body are intentional + 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 + ), + ), + fallback_action=dict(href="/", label=_("Go to Homepage")), + ) + + try: + processed = self._process_response(psl, response, ref_doc) + if self.flags.status_changed_to in self.flowstates.declined: + try: + msg = self._render_failure_message() + 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: + return Processed( + message=_("There's been an issue with your payment."), + action=get_compensatory_action(error), + status_changed_to=_("Server Error"), + indicator_color="red", + payload={}, + ) + + except PaymentControllerProcessingError as e: + error = psl.log_error(f"Processing error ({e.psltype})") + psl.set_processing_payload(response, "Error") + if not mute: + return Processed( + message=_error_value(error, e.psltype), + action=get_compensatory_action(error), + status_changed_to=_("Server Error"), + indicator_color="red", + payload={}, + ) + + except RefDocHookProcessingError as e: + error = psl.log_error(f"Processing failure ({e.psltype} - refdoc hook)", e.__cause__) + psl.set_processing_payload(response, "Error - RefDoc") + if not mute: + return Processed( + message=_error_value(error, f"{e.psltype} (via ref doc hook)"), + action=get_compensatory_action(error), + status_changed_to=_("Server Error"), + indicator_color="red", + payload={}, + ) + else: + return processed + finally: + psl.unlock() + + # Lifecycle hooks (contracts) + # - implement 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 + # - implement 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 _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 _initiate_charge(self) -> Initiated: + """Invoked by proceed in order to initiate a charge flow. + + Implementations can read: + - self.state.psl + - self.state.tx_data + """ + 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 + """ + raise NotImplementedError + + def _process_response_for_charge(self) -> Processed | None: + """Implement how the controller should process charge responses + + Needs to be idempotent. + + Implementations can read: + - self.state.psl + - self.state.tx_data + - self.state.response + """ + 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 + """ + 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 + """ + raise NotImplementedError + + +@frappe.whitelist() +def frontend_defaults(doctype): + if not isinstance(doctype, str): + frappe.throw(_("Invalid parameter"), frappe.ValidationError) + + # Only allow DocTypes that are registered as payment gateways + if not frappe.db.exists("Payment Gateway", {"gateway_settings": doctype}): + frappe.throw(_("Not a valid payment gateway"), frappe.ValidationError) + + c: PaymentController = get_controller(doctype) + if issubclass(c, PaymentController): + d: FrontendDefaults = c.frontend_defaults + return d.__dict__ diff --git a/payments/controllers/test_payment_controller.py b/payments/controllers/test_payment_controller.py new file mode 100644 index 00000000..dec90371 --- /dev/null +++ b/payments/controllers/test_payment_controller.py @@ -0,0 +1,305 @@ +import json +import unittest +from unittest.mock import MagicMock, patch + +import frappe +from frappe.tests import IntegrationTestCase + +from payments.controllers import PaymentController +from payments.exceptions import PaymentControllerProcessingError, RefDocHookProcessingError +from payments.payments.doctype.payment_session_log.payment_session_log import PaymentSessionLog +from payments.types import GatewayProcessingResponse, TxData + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_tx_data(**overrides): + """Create a valid TxData for testing.""" + defaults = dict( + amount=25.00, + currency="USD", + reference_doctype="User", + reference_docname="Administrator", + payer_contact={"email_id": "test@example.com"}, + payer_address={}, + loyalty_points=None, + discount_amount=None, + ) + defaults.update(overrides) + return TxData(**defaults) + + +# --------------------------------------------------------------------------- +# Unit tests — no database required +# --------------------------------------------------------------------------- + + +class TestExceptionContracts(unittest.TestCase): + """Verify exception classes store all attributes correctly. + + Regression: RefDocHookProcessingError was called with 1 arg but + constructor expects 2 — causing TypeError in error handling path. + """ + + def test_ref_doc_hook_error_stores_both_attributes(self): + err = RefDocHookProcessingError("hook failed", "charge") + self.assertEqual(err.message, "hook failed") + self.assertEqual(err.psltype, "charge") + + def test_ref_doc_hook_error_requires_two_args(self): + with self.assertRaises(TypeError): + RefDocHookProcessingError("only one arg") + + def test_processing_error_stores_both_attributes(self): + err = PaymentControllerProcessingError("processing failed", "mandate") + self.assertEqual(err.message, "processing failed") + self.assertEqual(err.psltype, "mandate") + + def test_processing_error_requires_two_args(self): + with self.assertRaises(TypeError): + PaymentControllerProcessingError("only one arg") + + +class TestPSLContracts(unittest.TestCase): + """Verify PSL methods required by the controller exist. + + Regression: update_gateway_specific_state was called by + pre_data_capture_hook but never defined on PaymentSessionLog. + """ + + def test_update_gateway_specific_state_is_callable(self): + self.assertTrue(hasattr(PaymentSessionLog, "update_gateway_specific_state")) + self.assertTrue(callable(PaymentSessionLog.update_gateway_specific_state)) + + +# --------------------------------------------------------------------------- +# Integration tests — require database + mocked Stripe SDK +# --------------------------------------------------------------------------- + + +STRIPE_MOCK_PATH = "payments.payment_gateways.doctype.stripe_settings.stripe_settings.stripe" + + +class TestPaymentControllerLifecycle(IntegrationTestCase): + """Integration tests for the PaymentController lifecycle. + + Uses StripeSettings as the concrete gateway with mocked Stripe SDK. + Tests the orchestration layer: initiate -> proceed -> process_response. + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + # Create a Stripe Settings for testing (skip API validation) + if not frappe.db.exists("Stripe Settings", "_Test Lifecycle"): + settings = frappe.get_doc( + { + "doctype": "Stripe Settings", + "gateway_name": "_Test Lifecycle", + "publishable_key": "pk_test_lifecycle", + "secret_key": "sk_test_lifecycle", + } + ) + settings.flags.ignore_mandatory = True + settings.insert(ignore_permissions=True) + + # Ensure Payment Gateway record exists + gateway_name = "Stripe-_Test Lifecycle" + if not frappe.db.exists("Payment Gateway", gateway_name): + frappe.get_doc( + { + "doctype": "Payment Gateway", + "gateway": gateway_name, + "gateway_settings": "Stripe Settings", + "gateway_controller": "_Test Lifecycle", + } + ).insert(ignore_permissions=True) + + cls.gateway_name = gateway_name + frappe.db.commit() + + def _mock_intent(self, id="pi_test_123", status="succeeded", **kwargs): + """Create a mock Stripe PaymentIntent.""" + intent = MagicMock() + intent.id = id + intent.client_secret = f"{id}_secret_xyz" + intent.status = status + for k, v in kwargs.items(): + setattr(intent, k, v) + return intent + + # -- initiate -- + + @patch(STRIPE_MOCK_PATH) + def test_initiate_creates_psl(self, mock_stripe): + """initiate() should create a PSL with status Created.""" + tx_data = _make_tx_data() + + _controller, psl_name = PaymentController.initiate(tx_data, self.gateway_name) + + self.assertIsNotNone(psl_name) + psl = frappe.get_doc("Payment Session Log", psl_name) + self.assertEqual(psl.status, "Created") + # Verify tx_data is stored + stored = json.loads(psl.tx_data) + self.assertEqual(stored["amount"], 25.00) + self.assertEqual(stored["currency"], "USD") + + @patch(STRIPE_MOCK_PATH) + def test_initiate_returns_controller_instance(self, mock_stripe): + """initiate() should return a PaymentController subclass instance.""" + tx_data = _make_tx_data() + + controller, _psl_name = PaymentController.initiate(tx_data, self.gateway_name) + + self.assertIsInstance(controller, PaymentController) + + # -- proceed -- + + @patch(STRIPE_MOCK_PATH) + def test_proceed_initiates_charge(self, mock_stripe): + """proceed() should call _initiate_charge and set PSL to Initiated.""" + mock_stripe.PaymentIntent.create.return_value = self._mock_intent() + + tx_data = _make_tx_data() + _controller, psl_name = PaymentController.initiate(tx_data, self.gateway_name) + + proceeded = PaymentController.proceed(psl_name) + + self.assertIsNotNone(proceeded) + self.assertEqual(proceeded.integration, "Stripe Settings") + self.assertIn("client_secret", proceeded.payload) + + psl = frappe.get_doc("Payment Session Log", psl_name) + self.assertEqual(psl.status, "Initiated") + self.assertEqual(psl.correlation_id, "pi_test_123") + + @patch(STRIPE_MOCK_PATH) + def test_proceed_is_idempotent(self, mock_stripe): + """proceed() called twice should return cached payload, not re-initiate.""" + mock_stripe.PaymentIntent.create.return_value = self._mock_intent() + + tx_data = _make_tx_data() + _controller, psl_name = PaymentController.initiate(tx_data, self.gateway_name) + + first = PaymentController.proceed(psl_name) + second = PaymentController.proceed(psl_name) + + # Stripe should only be called once + mock_stripe.PaymentIntent.create.assert_called_once() + self.assertEqual(first.payload, second.payload) + + # -- process_response: success -- + + @patch(STRIPE_MOCK_PATH) + def test_process_response_success(self, mock_stripe): + """process_response() with succeeded status should set PSL to Paid.""" + mock_stripe.PaymentIntent.create.return_value = self._mock_intent() + mock_stripe.PaymentIntent.retrieve.return_value = self._mock_intent(status="succeeded") + + tx_data = _make_tx_data() + _controller, psl_name = PaymentController.initiate(tx_data, self.gateway_name) + PaymentController.proceed(psl_name) + + response = GatewayProcessingResponse( + hash=None, + message=None, + payload={"id": "pi_test_123", "status": "succeeded"}, + ) + result = PaymentController.process_response(psl_name, response) + + self.assertIsNotNone(result) + self.assertEqual(result.indicator_color, "green") + + psl = frappe.get_doc("Payment Session Log", psl_name) + self.assertEqual(psl.status, "Paid") + + # -- process_response: declined -- + + @patch(STRIPE_MOCK_PATH) + def test_process_response_declined(self, mock_stripe): + """process_response() with declined status should set PSL to Declined.""" + mock_stripe.PaymentIntent.create.return_value = self._mock_intent() + mock_stripe.PaymentIntent.retrieve.return_value = self._mock_intent( + status="requires_payment_method" + ) + + tx_data = _make_tx_data() + _controller, psl_name = PaymentController.initiate(tx_data, self.gateway_name) + PaymentController.proceed(psl_name) + + response = GatewayProcessingResponse( + hash=None, + message=None, + payload={ + "id": "pi_test_123", + "status": "requires_payment_method", + "last_payment_error": {"message": "Card declined"}, + }, + ) + result = PaymentController.process_response(psl_name, response) + + self.assertIsNotNone(result) + self.assertEqual(result.indicator_color, "red") + + psl = frappe.get_doc("Payment Session Log", psl_name) + self.assertEqual(psl.status, "Declined") + + # -- process_response: ref doc hook error (regression for bug 1) -- + + @patch(STRIPE_MOCK_PATH) + def test_process_response_ref_doc_hook_error(self, mock_stripe): + """RefDoc hook error should produce Error-RefDoc status, not crash. + + Regression: RefDocHookProcessingError was called with wrong arg + count, causing TypeError instead of proper error handling. + """ + mock_stripe.PaymentIntent.create.return_value = self._mock_intent() + mock_stripe.PaymentIntent.retrieve.return_value = self._mock_intent(status="succeeded") + + tx_data = _make_tx_data() + _controller, psl_name = PaymentController.initiate(tx_data, self.gateway_name) + PaymentController.proceed(psl_name) + + # Patch the ref doc class to have a hook that raises + def exploding_hook(*args, **kwargs): + raise ValueError("hook exploded") + + response = GatewayProcessingResponse( + hash=None, + message=None, + payload={"id": "pi_test_123", "status": "succeeded"}, + ) + + ref_doc_class = frappe.get_doc("User", "Administrator").__class__ + with patch.object( + ref_doc_class, + "on_payment_charge_processed", + create=True, + new=exploding_hook, + ): + PaymentController.process_response(psl_name, response) + + psl = frappe.get_doc("Payment Session Log", psl_name) + self.assertEqual(psl.status, "Error - RefDoc") + + # -- pre_data_capture_hook (regression for bug 3) -- + + @patch(STRIPE_MOCK_PATH) + def test_pre_data_capture_hook_stores_state(self, mock_stripe): + """pre_data_capture_hook should call update_gateway_specific_state. + + Regression: update_gateway_specific_state did not exist on PSL. + """ + tx_data = _make_tx_data() + _controller, psl_name = PaymentController.initiate(tx_data, self.gateway_name) + + # pre_data_capture_hook calls _pre_data_capture_hook (returns {}) + # then calls psl.update_gateway_specific_state(data, "Data Capture") + data = PaymentController.pre_data_capture_hook(psl_name) + + self.assertIsInstance(data, dict) + psl = frappe.get_doc("Payment Session Log", psl_name) + self.assertEqual(psl.status, "Data Capture") 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 c6039c77..f4c66139 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/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..6197d849 --- /dev/null +++ b/payments/payments/doctype/payment_button/payment_button.js @@ -0,0 +1,21 @@ +// 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..00d7f485 --- /dev/null +++ b/payments/payments/doctype/payment_button/payment_button.json @@ -0,0 +1,161 @@ +{ + "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", + "implementation_variant", + "column_break_mjuo", + "label", + "enabled", + "button_configuration_section", + "column_break_zwhf", + "icon", + "gateway_css", + "gateway_js", + "gateway_wrapper", + "data_capture", + "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 + }, + { + "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 && doc.implementation_variant == \"Third Party Widget\"", + "options": "HTML" + }, + { + "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 && doc.implementation_variant == \"Third Party Widget\"", + "options": "HTML" + }, + { + "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 && doc.implementation_variant == \"Third Party Widget\"", + "options": "HTML" + }, + { + "default": "
\n
\n
\n \n\n \n
\n
\n
\n \n
\n
\n
\n
\n\n\n", + "fieldname": "column_break_zwhf", + "fieldtype": "Column Break" + }, + { + "description": "The label to show on the payment button on checkout pages", + "fieldname": "label", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Label", + "reqd": 1, + "unique": 1 + }, + { + "fieldname": "column_break_mjuo", + "fieldtype": "Column Break" + }, + { + "default": "0", + "fieldname": "enabled", + "fieldtype": "Check", + "label": "Enabled" + }, + { + "description": "The svg icon to the left of the payment button label", + "fieldname": "icon", + "fieldtype": "Attach Image", + "label": "Icon" + }, + { + "collapsible": 1, + "collapsible_depends_on": "eval: doc.enabled", + "description": "The code fields of this section are HTML snippets templated via jinja.
\n
{\n  \"doc\":     <Instance of PaymentController>,\n  \"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" + }, + { + "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" + }, + { + "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-06-17 15:09:04.579657", + "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..acfdac95 --- /dev/null +++ b/payments/payments/doctype/payment_button/payment_button.py @@ -0,0 +1,76 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and contributors +# License: MIT. See LICENSE + +import json + +import frappe +from frappe import _ +from frappe.model.document import Document + +from payments.payments.doctype.payment_session_log.payment_session_log import PSLState +from payments.types import RemoteServerInitiationPayload, TxData + +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 + + data_capture: DF.Code | None + 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 + implementation_variant: DF.Literal["Third Party Widget", "Data Capture"] + label: DF.Data + # end: auto-generated types + + # Frontend Assets (widget) + # - implement them for your controller + # - need to be fully rendered with + # --------------------------------------- + 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), + "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 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) + return frappe.render_template(self.data_capture, context) + + @property + def requires_data_capture(self): + return self.implementation_variant == "Data Capture" + + 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..8995c771 --- /dev/null +++ b/payments/payments/doctype/payment_button/test_payment_button.py @@ -0,0 +1,29 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: MIT. See LICENSE +import unittest + +import frappe + + +class TestPaymentButtonProperty(unittest.TestCase): + """Verify requires_data_capture property exists and works. + + Regression: property was misspelled as requires_data_catpure, + and was accessed on PSL instead of PaymentButton. + """ + + def test_requires_data_capture_true_for_data_capture_variant(self): + btn = frappe.new_doc("Payment Button") + btn.implementation_variant = "Data Capture" + self.assertTrue(btn.requires_data_capture) + + def test_requires_data_capture_false_for_widget_variant(self): + btn = frappe.new_doc("Payment Button") + btn.implementation_variant = "Third Party Widget" + self.assertFalse(btn.requires_data_capture) + + def test_requires_data_capture_attribute_exists(self): + """Property should be accessible — not raise AttributeError.""" + btn = frappe.new_doc("Payment Button") + # This would have raised AttributeError with the old typo + _ = btn.requires_data_capture 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..5232c1f3 --- /dev/null +++ b/payments/payments/doctype/payment_session_log/payment_session_log.js @@ -0,0 +1,6 @@ +// Copyright (c) 2021, Frappe and contributors +// For license information, please see LICENSE + +frappe.ui.form.on("Payment Session Log", { + refresh: function (frm) {}, +}); 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..3143c54b --- /dev/null +++ b/payments/payments/doctype/payment_session_log/payment_session_log.json @@ -0,0 +1,141 @@ +{ + "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, + "search_index": 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, + "search_index": 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": "2026-01-19 21:54:25.969842", + "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..bcc03925 --- /dev/null +++ b/payments/payments/doctype/payment_session_log/payment_session_log.py @@ -0,0 +1,249 @@ +# Copyright (c) 2021, Frappe and contributors +# For license information, please see LICENSE + +import dataclasses +import json +from typing import TYPE_CHECKING, ClassVar, TypedDict + +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 payments.types import GatewayProcessingResponse, RemoteServerInitiationPayload, TxData + +if TYPE_CHECKING: + from payments.controllers import PaymentController + from payments.payments.doctype.payment_button.payment_button import PaymentButton + + +class PSLState(TypedDict): + """State returned by PaymentSessionLog.load_state()""" + + psl: dict + tx_data: TxData + + +class PaymentSessionLog(Document): + # TODO: Remove vestigial `mandate` field from payment_session_log.json + # The mandate system was removed from PaymentController but the DocType field + # remains to avoid a schema migration. Clean up when convenient. + + # 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 + + # Centralized terminal state definitions - single source of truth + # Used by /pay endpoint and other consumers + TERMINAL_STATES: ClassVar[dict[str, str]] = { + "Paid": "green", + "Authorized": "green", + "Processing": "yellow", + "Declined": "red", + "Cancelled": "red", + "Error": "red", + "Error - RefDoc": "red", + } + + def is_terminal(self) -> bool: + """Check if PSL is in a terminal state (no further action possible).""" + return self.status in self.TERMINAL_STATES + + def get_indicator_color(self) -> str: + """Get the indicator color for the current status.""" + return self.TERMINAL_STATES.get(self.status, "gray") + + 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 update_gateway_specific_state(self, data: dict, status: str) -> None: + """Store gateway-specific state during data capture phase.""" + self.db_set( + { + "initiation_response_payload": 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 == "Paid") + ) + + +@frappe.whitelist(allow_guest=True) +def select_button(pslName: str | None = None, buttonName: str | None = None) -> str: + """Select a payment button for a payment session. + + Security validations: + - Button must be enabled + - Button must match PSL gateway filter (if set) + - PSL must be in a pre-terminal state (not already paid/failed) + """ + try: + psl = frappe.get_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 + + # Validate PSL is in a state where button selection is allowed + if psl.is_terminal(): + frappe.log_error( + f"Attempted button selection on terminal PSL: {pslName} (status: {psl.status})", + reference_doctype="Payment Session Log", + ) + frappe.local.message_log = [_("This payment session is no longer active.")] + 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 + + # Validate button is enabled + if not btn.enabled: + frappe.log_error( + f"Attempted to select disabled button: {buttonName}", + reference_doctype="Payment Button", + ) + frappe.local.message_log = [_("This payment method is not available.")] + return + + # Validate button matches PSL gateway filter (if set) + if psl.gateway: + try: + gateway_filter = json.loads(psl.gateway) + # Check if selected button matches the required gateway settings/controller + if ( + gateway_filter.get("gateway_settings") + and gateway_filter["gateway_settings"] != btn.gateway_settings + ): + frappe.log_error( + f"Button gateway mismatch: expected {gateway_filter.get('gateway_settings')}, got {btn.gateway_settings}", + reference_doctype="Payment Session Log", + ) + frappe.local.message_log = [_("This payment method is not available for this transaction.")] + return + if ( + gateway_filter.get("gateway_controller") + and gateway_filter["gateway_controller"] != btn.gateway_controller + ): + frappe.log_error( + f"Button controller mismatch: expected {gateway_filter.get('gateway_controller')}, got {btn.gateway_controller}", + reference_doctype="Payment Session Log", + ) + frappe.local.message_log = [_("This payment method is not available for this transaction.")] + return + except (json.JSONDecodeError, TypeError): + pass # No valid gateway filter, allow any button + + 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") + # TxData is a dataclass — convert to dict for JSON serialization + tx_data_dict = dataclasses.asdict(tx_data) if dataclasses.is_dataclass(tx_data) else tx_data + log.tx_data = frappe.as_json(tx_data_dict) + log.status = status + if controller: + log.gateway = json.dumps( + { + "gateway_settings": controller.doctype, + "gateway_controller": controller.name, + } + ) + + log.insert(ignore_permissions=True) + return log 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..71bff92e --- /dev/null +++ b/payments/payments/doctype/payment_session_log/payment_session_log_list.js @@ -0,0 +1,22 @@ +frappe.listview_settings["Payment Session Log"] = { + hide_name_column: true, + add_fields: ["status"], + get_indicator: function (doc) { + const indicators = { + Paid: ["green"], + Authorized: ["green"], + Processing: ["yellow"], + Created: ["blue"], + Started: ["blue"], + Initiated: ["orange"], + Declined: ["red"], + Error: ["red"], + "Error - RefDoc": ["red"], + Cancelled: ["red"], + }; + const match = indicators[doc.status]; + if (match) { + return [__(doc.status), match[0], "status,=," + doc.status]; + } + }, +}; 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..0fa83fe5 --- /dev/null +++ b/payments/payments/doctype/payment_session_log/test_payment_session_log.py @@ -0,0 +1,176 @@ +# Copyright (c) 2021, Frappe and Contributors +# See LICENSE + +import json +import unittest +from unittest.mock import MagicMock, patch + +import frappe +from frappe.tests import IntegrationTestCase + +from payments.payments.doctype.payment_session_log.payment_session_log import ( + PaymentSessionLog, + select_button, +) + + +class TestPaymentSessionLogTerminalStates(unittest.TestCase): + """Unit tests for terminal state methods.""" + + def test_is_terminal_returns_true_for_paid(self): + psl = PaymentSessionLog.__new__(PaymentSessionLog) + psl.status = "Paid" + self.assertTrue(psl.is_terminal()) + + def test_is_terminal_returns_true_for_declined(self): + psl = PaymentSessionLog.__new__(PaymentSessionLog) + psl.status = "Declined" + self.assertTrue(psl.is_terminal()) + + def test_is_terminal_returns_false_for_created(self): + psl = PaymentSessionLog.__new__(PaymentSessionLog) + psl.status = "Created" + self.assertFalse(psl.is_terminal()) + + def test_is_terminal_returns_false_for_initiated(self): + psl = PaymentSessionLog.__new__(PaymentSessionLog) + psl.status = "Initiated" + self.assertFalse(psl.is_terminal()) + + def test_get_indicator_color_returns_green_for_paid(self): + psl = PaymentSessionLog.__new__(PaymentSessionLog) + psl.status = "Paid" + self.assertEqual(psl.get_indicator_color(), "green") + + def test_get_indicator_color_returns_red_for_error(self): + psl = PaymentSessionLog.__new__(PaymentSessionLog) + psl.status = "Error" + self.assertEqual(psl.get_indicator_color(), "red") + + def test_get_indicator_color_returns_gray_for_unknown(self): + psl = PaymentSessionLog.__new__(PaymentSessionLog) + psl.status = "SomeUnknownStatus" + self.assertEqual(psl.get_indicator_color(), "gray") + + +class TestSelectButtonAuthorization(IntegrationTestCase): + """Integration tests for select_button security validations.""" + + def setUp(self): + # Create a test Stripe Settings (Gateway Controller) first + if not frappe.db.exists("Stripe Settings", "_Test Controller"): + self.controller = frappe.get_doc( + { + "doctype": "Stripe Settings", + "gateway_name": "_Test Controller", + "publishable_key": "pk_test_dummy", + "secret_key": "sk_test_dummy", + } + ) + self.controller.flags.ignore_mandatory = True # Skip API key validation + self.controller.insert(ignore_permissions=True) + + # Create a test payment button (autoname from label) + if not frappe.db.exists("Payment Button", "_Test PSL Button"): + self.btn = frappe.get_doc( + { + "doctype": "Payment Button", + "label": "_Test PSL Button", + "enabled": 1, + "gateway_settings": "Stripe Settings", + "gateway_controller": "_Test Controller", + } + ) + self.btn.insert(ignore_permissions=True) + else: + self.btn = frappe.get_doc("Payment Button", "_Test PSL Button") + + # Create a disabled button for testing + if not frappe.db.exists("Payment Button", "_Test PSL Disabled"): + self.disabled_btn = frappe.get_doc( + { + "doctype": "Payment Button", + "label": "_Test PSL Disabled", + "enabled": 0, + "gateway_settings": "Stripe Settings", + "gateway_controller": "_Test Controller", + } + ) + self.disabled_btn.insert(ignore_permissions=True) + + def _create_psl(self, status="Created", gateway=None): + """Helper to create a test PSL.""" + psl = frappe.get_doc( + { + "doctype": "Payment Session Log", + "status": status, + "tx_data": json.dumps({"amount": 100, "currency": "USD"}), + "gateway": gateway, + } + ) + psl.insert(ignore_permissions=True) + return psl + + def test_select_button_rejects_disabled_button(self): + """select_button should reject disabled buttons.""" + psl = self._create_psl() + + result = select_button(pslName=psl.name, buttonName="_Test PSL Disabled") + + self.assertIsNone(result) + psl.reload() + self.assertIsNone(psl.button) + + def test_select_button_rejects_terminal_psl(self): + """select_button should reject PSL in terminal state.""" + psl = self._create_psl(status="Paid") + + result = select_button(pslName=psl.name, buttonName="_Test PSL Button") + + self.assertIsNone(result) + psl.reload() + self.assertIsNone(psl.button) + + def test_select_button_rejects_mismatched_gateway(self): + """select_button should reject button that doesn't match PSL gateway filter.""" + # PSL requires a specific gateway + gateway_filter = json.dumps( + { + "gateway_settings": "GoCardless Settings", + "gateway_controller": "Different Controller", + } + ) + psl = self._create_psl(gateway=gateway_filter) + + result = select_button(pslName=psl.name, buttonName="_Test PSL Button") + + self.assertIsNone(result) + psl.reload() + self.assertIsNone(psl.button) + + def test_select_button_accepts_valid_selection(self): + """select_button should accept valid button selection.""" + psl = self._create_psl() + + result = select_button(pslName=psl.name, buttonName="_Test PSL Button") + + self.assertIsNotNone(result) + self.assertTrue(result.get("reload")) + psl.reload() + self.assertEqual(psl.button, "_Test PSL Button") + + def test_select_button_accepts_matching_gateway(self): + """select_button should accept button matching PSL gateway filter.""" + gateway_filter = json.dumps( + { + "gateway_settings": "Stripe Settings", + "gateway_controller": "_Test Controller", + } + ) + psl = self._create_psl(gateway=gateway_filter) + + result = select_button(pslName=psl.name, buttonName="_Test PSL Button") + + self.assertIsNotNone(result) + psl.reload() + self.assertEqual(psl.button, "_Test PSL Button") diff --git a/payments/types.py b/payments/types.py new file mode 100644 index 00000000..9586a458 --- /dev/null +++ b/payments/types.py @@ -0,0 +1,187 @@ +from dataclasses import dataclass +from enum import Enum + + +class SessionType(str, Enum): + """Payment flow types.""" + + charge = "charge" + + +@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() + 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 + + +@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 + + +@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 + 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: dict + + +# 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 1a494cb7..98325354 100644 --- a/payments/utils/__init__.py +++ b/payments/utils/__init__.py @@ -1,8 +1,13 @@ from payments.utils.utils import ( + PAYMENT_SESSION_REF_KEY, before_install, create_payment_gateway, delete_custom_fields, erpnext_app_import_guard, get_payment_gateway_controller, + is_v2_gateway, make_custom_fields, ) + +# Alias for backwards compatibility with older erpnext versions <16 +get_payment_controller = get_payment_gateway_controller diff --git a/payments/utils/utils.py b/payments/utils/utils.py index 64e79a99..d09adea5 100644 --- a/payments/utils/utils.py +++ b/payments/utils/utils.py @@ -1,18 +1,51 @@ from contextlib import contextmanager +from typing import TYPE_CHECKING import click import frappe from frappe import _ from frappe.custom.doctype.custom_field.custom_field import create_custom_fields +from payments.types import PSLName + +if TYPE_CHECKING: + from frappe.model.document import Document + + from payments.controllers import PaymentController + from payments.payments.doctype.payment_session_log.payment_session_log import ( + PaymentSessionLog, + ) + +# Key used to identify the payment session on the frappe/erpnext side across its lifecycle +PAYMENT_SESSION_REF_KEY = "s" + def validate_integration_request(docname: str | None): if frappe.db.get_value("Integration Request", docname, "status") == "Cancelled": frappe.throw(_("Expired Token")) -def get_payment_gateway_controller(payment_gateway): - """Return payment gateway controller""" +def get_payment_gateway_controller(payment_gateway: str) -> "Document": + """Return the payment gateway controller settings document instance. + + This function always returns a Document **instance** (never a class). + + The returned instance is the settings document for the gateway (e.g., "Stripe Settings"), + which may or may not inherit from PaymentController: + - V2 gateways: Instance of a Document subclass that inherits from PaymentController + - V1 gateways: Instance of a Document subclass that does NOT inherit from PaymentController + + Use `is_v2_gateway()` to check if a gateway implements the PaymentController interface. + + Args: + payment_gateway: The name of the Payment Gateway document + + Returns: + Document instance of the gateway's settings DocType + + Raises: + frappe.ValidationError: If the gateway settings document is not found + """ gateway = frappe.get_doc("Payment Gateway", payment_gateway) if gateway.gateway_controller is None: try: @@ -26,11 +59,52 @@ def get_payment_gateway_controller(payment_gateway): frappe.throw(_("{0} Settings not found").format(payment_gateway)) +def is_v2_gateway(payment_gateway: str) -> bool: + """Check if a payment gateway implements the PaymentController interface (v2). + + This function safely determines whether a gateway uses the new PaymentController + architecture (v2) or the legacy architecture (v1). Use this to conditionally + choose the appropriate payment flow. + + The check is defensive and handles both edge cases: + - If controller is a class: uses issubclass() + - If controller is an instance: uses isinstance() + + Note: get_payment_gateway_controller() always returns an instance, but this + defensive check is maintained for API robustness. + + Args: + payment_gateway: The name of the Payment Gateway document + + Returns: + True if the gateway implements PaymentController (v2), False otherwise. + Returns False if the gateway doesn't exist or PaymentController cannot be imported. + """ + if not payment_gateway: + return False + + try: + from payments.controllers import PaymentController + except ImportError: + return False + + try: + controller = get_payment_gateway_controller(payment_gateway) + except Exception: + return False + + # Defensive check: handle both class and instance (even though the function + # currently always returns an instance, this ensures API robustness) + if isinstance(controller, type): + return issubclass(controller, PaymentController) + return isinstance(controller, PaymentController) + + @frappe.whitelist(allow_guest=True, xss_safe=True) def get_checkout_url(**kwargs): try: if kwargs.get("payment_gateway"): - doc = frappe.get_doc("{} Settings".format(kwargs.get("payment_gateway"))) + doc = get_payment_gateway_controller(kwargs.get("payment_gateway")) return doc.get_payment_url(**kwargs) else: raise Exception @@ -155,7 +229,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) @@ -166,28 +251,25 @@ def delete_custom_fields(): return click.secho("* Uninstalling Payment Custom Fields from Web Form") - frappe.db.delete( - "Custom Field", - { - "dt": "Web Form", - "fieldname": ( - "in", - ( - "payments_tab", - "accept_payment", - "payment_gateway", - "payment_button_label", - "payment_button_help", - "payments_cb", - "amount_field", - "amount_based_on_field", - "amount", - "currency", - ), - ), - }, + + fieldnames = ( + "payments_tab", + "accept_payment", + "payment_gateway", + "payment_button_label", + "payment_button_help", + "payments_cb", + "amount_field", + "amount_based_on_field", + "amount", + "currency", ) + 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..573e05d6 --- /dev/null +++ b/payments/www/pay.html @@ -0,0 +1,168 @@ +{% 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 -%} + {% 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 %} +
+

+ + +

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

+ DOM Loaded: Not yet
+ Global Variables: Checking...
+

+
+ + {% endif %} + {% if logo %} + + {% endif %} + {% if render_widget %} +
+ + {{ gateway_wrapper }} +
+ {% 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:] %} +
+
+ {% for secondary_button in secondary_buttons %} +
+ {% endfor %} +
+
+ {% endif %} +
+
+{% endblock %} + +{% block base_scripts %} +{% if render_widget %} + {{ gateway_js}} +{% endif %} + + +{{ include_script('website-core.bundle.js') }} + +{% endblock %} + +{% set web_include_js=[] %} diff --git a/payments/www/pay.js b/payments/www/pay.js new file mode 100644 index 00000000..10e83970 --- /dev/null +++ b/payments/www/pay.js @@ -0,0 +1,72 @@ +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("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; + const pill = $("#status"); + pill.text(status); + pill.addClass(color); + $("#status-wrapper").toggle(true); + const indicator = $("#refdoc-indicator"); + indicator.removeClass(function (_, className) { + const matches = className.match(/blue|red|yellow|gray/g) || []; + return matches.join(" "); + }); + indicator.addClass(color); + } + if (r.message.message) { + $("#message").text(r.message.message).toggle(true); + $("#message-wrapper").toggle(true); + } + if (r.message.action) { + const cta = $("#action-processed"); + cta.text(r.message.action.label); + const href = r.message.action.href || ""; + const isSafeHref = /^(\/|https?:\/\/)/.test(href); + cta.attr("href", isSafeHref ? href : "/"); + cta.toggle(true); + cta.focus(); + } +}); diff --git a/payments/www/pay.py b/payments/www/pay.py new file mode 100644 index 00000000..d0f04dad --- /dev/null +++ b/payments/www/pay.py @@ -0,0 +1,117 @@ +import json +from typing import TYPE_CHECKING + +import frappe +from frappe import _ +from frappe.utils.file_manager import get_file_path + +from payments.controllers import PaymentController +from payments.types import Proceeded, RemoteServerInitiationPayload, TxData +from payments.utils import PAYMENT_SESSION_REF_KEY + +if TYPE_CHECKING: + from payments.payments.doctype.payment_button.payment_button import PaymentButton + from payments.payments.doctype.payment_session_log.payment_session_log import PaymentSessionLog + +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 + + # Debug mode only for authenticated users in developer mode + context.debug = ( + frappe.form_dict.get("debug") == "1" + and frappe.conf.get("developer_mode") + and frappe.session.user != "Guest" + ) + + psl: PaymentSessionLog = get_psl() + state = psl.load_state() + context.tx_data: TxData = state.tx_data + context.logo = frappe.get_website_settings("app_logo") or frappe.get_hooks("app_logo_url")[-1] + + # Not reached a terminal state, yet + # A terminal error state would require operator intervention, first + if not psl.is_terminal(): + # First Pass: chose payment button + # 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, + ) + + # Use already-fetched buttons instead of re-querying + context.payment_buttons = [ + (load_icon(entry.get("icon")), entry.get("name"), entry.get("label")) for entry in buttons + ] + context.render_buttons = True + + if not psl.button: + context.render_widget = False + context.render_capture = False + + # Second Pass (Data Capture): capture additonal data if the button requires it + elif psl.get_button().requires_data_capture: + context.render_widget = False + context.render_capture = True + + 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_capture = False + + 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.indicator_color = psl.get_indicator_color()