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 \"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!+ + {% 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 %} +
+ DOM Loaded: Not yet
+ Global Variables: Checking...
+