diff --git a/commerce_coordinator/apps/commercetools/catalog_info/edx_utils.py b/commerce_coordinator/apps/commercetools/catalog_info/edx_utils.py index ed7a5edc6..c2e01b5fe 100644 --- a/commerce_coordinator/apps/commercetools/catalog_info/edx_utils.py +++ b/commerce_coordinator/apps/commercetools/catalog_info/edx_utils.py @@ -11,6 +11,7 @@ from commerce_coordinator.apps.commercetools.catalog_info.constants import ( EDX_STRIPE_PAYMENT_INTERFACE_NAME, + EDX_PAYPAL_PAYMENT_INTERFACE_NAME, PAYMENT_STATUS_INTERFACE_CODE_SUCCEEDED, EdXFieldNames, TwoUKeys @@ -58,23 +59,22 @@ def get_edx_successful_stripe_payment(order: CTOrder) -> Union[CTPayment, None]: return None -def get_edx_payment_intent_id(order: CTOrder) -> Union[str, None]: - pmt = get_edx_successful_stripe_payment(order) - if pmt: - return pmt.interface_id - return None - - -# TODO update get_edx_successful_stripe_payment to accommodate this util logic -# and replace it with that. +# TODO remove get_edx_successful_stripe_payment if there is no more use. def get_edx_successful_payment_info(order: CTOrder): for pr in order.payment_info.payments: pmt = pr.obj if pmt.payment_status.interface_code == PAYMENT_STATUS_INTERFACE_CODE_SUCCEEDED and pmt.interface_id: - return pmt.interface_id, pmt.payment_method_info.payment_interface + return pmt, pmt.payment_method_info.payment_interface return None, None +def get_edx_payment_intent_id(order: CTOrder) -> Union[str, None]: + pmt, _ = get_edx_successful_payment_info(order) + if pmt: + return pmt.interface_id + return None + + def get_edx_order_workflow_state_key(order: CTOrder) -> Optional[str]: order_workflow_state = None if order.state and order.state.obj: # it should never be that we have one and not the other. # pragma no cover @@ -88,7 +88,7 @@ def get_edx_is_sanctioned(order: CTOrder) -> bool: def get_edx_refund_amount(order: CTOrder) -> decimal: refund_amount = decimal.Decimal(0.00) - pmt = get_edx_successful_stripe_payment(order) + pmt, _ = get_edx_successful_payment_info(order) for transaction in pmt.transactions: if transaction.type == TransactionType.CHARGE: # pragma no cover refund_amount += decimal.Decimal(typed_money_to_string(transaction.amount, money_as_decimal_string=True)) diff --git a/commerce_coordinator/apps/commercetools/pipeline.py b/commerce_coordinator/apps/commercetools/pipeline.py index 5eaafef2b..4b4fc61bf 100644 --- a/commerce_coordinator/apps/commercetools/pipeline.py +++ b/commerce_coordinator/apps/commercetools/pipeline.py @@ -12,9 +12,11 @@ from openedx_filters.exceptions import OpenEdxFilterException from requests import HTTPError -from commerce_coordinator.apps.commercetools.catalog_info.constants import EDX_STRIPE_PAYMENT_INTERFACE_NAME +from commerce_coordinator.apps.commercetools.catalog_info.constants import ( + EDX_STRIPE_PAYMENT_INTERFACE_NAME, + EDX_PAYPAL_PAYMENT_INTERFACE_NAME +) from commerce_coordinator.apps.commercetools.catalog_info.edx_utils import ( - get_edx_payment_intent_id, get_edx_refund_amount, get_edx_successful_payment_info ) @@ -110,12 +112,12 @@ def run_filter(self, active_order_management_system, order_number, **kwargs): # duration = (datetime.now() - start_time).total_seconds() log.info(f"[Performance Check] get_order_by_number call took {duration} seconds") - intent_id, psp = get_edx_successful_payment_info(ct_order) + payment, psp = get_edx_successful_payment_info(ct_order) ret_val = { "order_data": ct_order, "psp": psp, - "payment_intent_id": intent_id + "payment_intent_id": payment.interface_id } return ret_val @@ -156,21 +158,21 @@ def run_filter(self, active_order_management_system, order_id, **kwargs): # pyl duration = (datetime.now() - start_time).total_seconds() log.info(f"[Performance Check] get_order_by_id call took {duration} seconds") + payment, psp = get_edx_successful_payment_info(ct_order) + ret_val = { "order_data": ct_order, - "order_id": ct_order.id + "order_id": ct_order.id, + "psp": psp, + "payment_intent_id": payment.interface_id } - intent_id = get_edx_payment_intent_id(ct_order) - - if intent_id: - ct_payment = ct_api_client.get_payment_by_key(intent_id) - ret_val['payment_intent_id'] = intent_id + if payment: + ct_payment = ct_api_client.get_payment_by_key(payment.interface_id) ret_val['amount_in_cents'] = get_edx_refund_amount(ct_order) ret_val['has_been_refunded'] = has_refund_transaction(ct_payment) ret_val['payment_data'] = ct_payment else: - ret_val['payment_intent_id'] = None ret_val['amount_in_cents'] = decimal.Decimal(0.00) ret_val['has_been_refunded'] = False ret_val['payment_data'] = None @@ -288,7 +290,7 @@ def run_filter( class CreateReturnPaymentTransaction(PipelineStep): """ Creates a Transaction for a return payment of a Commercetools order - based on Stripes refund object on a refunded charge. + based on Stripes or PayPal refund object on a refunded charge. """ def run_filter( @@ -297,12 +299,13 @@ def run_filter( active_order_management_system, payment_data, has_been_refunded, + psp, **kwargs ): # pylint: disable=arguments-differ """ Execute a filter with the signature specified. Arguments: - refund_response: Stripe refund object or str value "charge_already_refunded" + refund_response: PSP refund object or str value "charge_already_refunded" active_order_management_system: The Active Order System payment_data: CT payment object attached to the refunded order has_been_refunded (bool): Has this payment been refunded @@ -325,28 +328,37 @@ def run_filter( try: if payment_data is not None: payment_on_order = payment_data - else: + elif psp == EDX_STRIPE_PAYMENT_INTERFACE_NAME: payment_key = refund_response['payment_intent'] payment_on_order = ct_api_client.get_payment_by_key(payment_key) + elif psp == EDX_PAYPAL_PAYMENT_INTERFACE_NAME: + payment_on_order = ct_api_client.get_payment_by_transaction_interaction_id(refund_response['paypal_capture_id']) updated_payment = ct_api_client.create_return_payment_transaction( payment_id=payment_on_order.id, payment_version=payment_on_order.version, - refund=refund_response + refund=refund_response, + psp=psp, ) return { 'returned_payment': updated_payment } except CommercetoolsError as err: # pragma no cover + if psp == EDX_STRIPE_PAYMENT_INTERFACE_NAME: + error_message = f"[payment_intent_id: {refund_response['payment_intent']}, " + elif psp == EDX_PAYPAL_PAYMENT_INTERFACE_NAME: + error_message = f"[paypal_capture_id: {refund_response['paypal_capture_id']}, " log.info(f"[{tag}] Unsuccessful attempt to create refund payment transaction with details: " - f"[stripe_payment_intent_id: {refund_response['payment_intent']}, " + f"psp: {psp}, " + f"{error_message}" f"payment_id: {payment_on_order.id}], message_id: {kwargs['message_id']}") log.exception(f"[{tag}] Commercetools Error: {err}, {err.errors}") return PipelineCommand.CONTINUE.value except HTTPError as err: # pragma no cover log.info(f"[{tag}] Unsuccessful attempt to create refund payment transaction with details: " - f"[stripe_payment_intent_id: {refund_response['payment_intent']}, " + f"psp: {psp}, " + f"{error_message}" f"payment_id: {payment_on_order.id}], message_id: {kwargs['message_id']}") log.exception(f"[{tag}] HTTP Error: {err}") return PipelineCommand.CONTINUE.value diff --git a/commerce_coordinator/apps/commercetools/tests/test_pipeline.py b/commerce_coordinator/apps/commercetools/tests/test_pipeline.py index 1f02f1dec..2c3306049 100644 --- a/commerce_coordinator/apps/commercetools/tests/test_pipeline.py +++ b/commerce_coordinator/apps/commercetools/tests/test_pipeline.py @@ -3,6 +3,7 @@ from unittest import TestCase from unittest.mock import patch +from commerce_coordinator.apps.commercetools.catalog_info.constants import EDX_STRIPE_PAYMENT_INTERFACE_NAME from commercetools.platform.models import ReturnInfo, ReturnPaymentState, ReturnShipmentState, TransactionType from django.contrib.auth import get_user_model from django.test import RequestFactory @@ -146,7 +147,8 @@ def test_commercetools_transaction_create(self, mock_returned_payment, mock_paym payment_data=self.mock_response_payment, refund_response={"payment_intent": "mock_payment_intent"}, active_order_management_system=COMMERCETOOLS_ORDER_MANAGEMENT_SYSTEM, - has_been_refunded=False + has_been_refunded=False, + psp=EDX_STRIPE_PAYMENT_INTERFACE_NAME ) mock_payment_result = ret['returned_payment'] @@ -167,7 +169,8 @@ def test_commercetools_transaction_create_no_payment_data(self, mock_returned_pa payment_data=None, refund_response={"payment_intent": "mock_payment_intent"}, active_order_management_system=COMMERCETOOLS_ORDER_MANAGEMENT_SYSTEM, - has_been_refunded=False + has_been_refunded=False, + psp=EDX_STRIPE_PAYMENT_INTERFACE_NAME ) mock_payment_result = ret['returned_payment'] @@ -184,7 +187,8 @@ def test_commercetools_transaction_create_has_refund(self, mock_logger, mock_has payment_data=self.mock_response_payment, refund_response="charge_already_refunded", active_order_management_system=COMMERCETOOLS_ORDER_MANAGEMENT_SYSTEM, - has_been_refunded=True + has_been_refunded=True, + psp=EDX_STRIPE_PAYMENT_INTERFACE_NAME ) mock_logger.assert_called_once_with('[CreateReturnPaymentTransaction] refund has already been processed, ' 'skipping refund payment transaction creation') diff --git a/commerce_coordinator/apps/paypal/clients.py b/commerce_coordinator/apps/paypal/clients.py new file mode 100644 index 000000000..e7c343b78 --- /dev/null +++ b/commerce_coordinator/apps/paypal/clients.py @@ -0,0 +1,53 @@ +import logging + +from django.conf import settings +from paypalserversdk.http.auth.o_auth_2 import ClientCredentialsAuthCredentials +from paypalserversdk.logging.configuration.api_logging_configuration import ( + LoggingConfiguration, + RequestLoggingConfiguration, + ResponseLoggingConfiguration, +) +from paypalserversdk.paypalserversdk_client import PaypalserversdkClient +from paypalserversdk.controllers.orders_controller import OrdersController +from paypalserversdk.controllers.payments_controller import PaymentsController +from paypalserversdk.api_helper import ApiHelper + + +class PayPalClient: + def __init__(self): + self.paypal_client: PaypalserversdkClient = PaypalserversdkClient( + client_credentials_auth_credentials=ClientCredentialsAuthCredentials( + o_auth_client_id=settings.PAYMENT_PROCESSOR_CONFIG['edx']['paypal']['client_id'], + o_auth_client_secret=settings.PAYMENT_PROCESSOR_CONFIG['edx']['paypal']['client_secret'], + ), + logging_configuration=LoggingConfiguration( + log_level=logging.INFO, + # Disable masking of sensitive headers for Sandbox testing. + # This should be set to True (the default if unset)in production. + mask_sensitive_headers=False, + request_logging_config=RequestLoggingConfiguration( + log_headers=True, log_body=True + ), + response_logging_config=ResponseLoggingConfiguration( + log_headers=True, log_body=True + ), + ), + ) + + + def refund_order(self, order_id): + paypal_client = self.paypal_client + orders_controller: OrdersController = paypal_client.orders + payments_controller: PaymentsController = paypal_client.payments + + order = orders_controller.orders_get({"id": order_id}) + + capture_id = order.body.purchase_units[0].payments.captures[0].id + + collect = {"capture_id": capture_id, "prefer": "return=minimal"} + result = payments_controller.captures_refund(collect) + + if result.body: + return {"paypal_capture_id": capture_id} + + return None diff --git a/commerce_coordinator/apps/paypal/pipeline.py b/commerce_coordinator/apps/paypal/pipeline.py index 9d4841d9c..5b7e589bf 100644 --- a/commerce_coordinator/apps/paypal/pipeline.py +++ b/commerce_coordinator/apps/paypal/pipeline.py @@ -6,10 +6,11 @@ from urllib.parse import urlencode from django.conf import settings +from commerce_coordinator.apps.paypal.clients import PayPalClient +from commerce_coordinator.apps.core.constants import PipelineCommand from openedx_filters import PipelineStep from commerce_coordinator.apps.commercetools.catalog_info.constants import EDX_PAYPAL_PAYMENT_INTERFACE_NAME -from commerce_coordinator.apps.core.constants import PipelineCommand logger = logging.getLogger(__name__) @@ -30,3 +31,51 @@ def run_filter(self, psp=None, payment_intent_id=None, **params): return { 'redirect_url': redirect_url, } + + +class RefundPayPalPayment(PipelineStep): + """ + Refunds a PayPal payment + """ + + def run_filter( + self, + order_id, + amount_in_cents, + has_been_refunded, + psp, + **kwargs + ): # pylint: disable=arguments-differ + """ + Execute a filter with the signature specified. + Arguments: + order_id (str): The identifier of the order. + amount_in_cents (decimal): Total amount to refund + has_been_refunded (bool): Has this payment been refunded + kwargs: arguments passed through from the filter. + """ + + tag = type(self).__name__ + + if psp != EDX_PAYPAL_PAYMENT_INTERFACE_NAME and not amount_in_cents: + return PipelineCommand.CONTINUE.value + + if has_been_refunded: + logger.info(f'[{tag}] payment already refunded from psp: {psp}, skipping.') + return { + 'refund_response': "charge_already_refunded" + } + + paypal_client = PayPalClient() + try: + paypal_refund_response = paypal_client.refund_order(order_id=order_id) + return { + 'refund_response': paypal_refund_response + } + except Exception as ex: + logger.info(f'[CT-{tag}] Unsuccessful PayPal refund with details:' + f'[order_id: {order_id}' + f'message_id: {kwargs["message_id"]}') + raise Exception from ex + + diff --git a/commerce_coordinator/apps/stripe/pipeline.py b/commerce_coordinator/apps/stripe/pipeline.py index 1840223de..41395e8a6 100644 --- a/commerce_coordinator/apps/stripe/pipeline.py +++ b/commerce_coordinator/apps/stripe/pipeline.py @@ -218,7 +218,7 @@ class GetPaymentIntentReceipt(PipelineStep): def run_filter(self, psp=None, payment_intent_id=None, **params): tag = type(self).__name__ - if payment_intent_id is None: + if psp == EDX_STRIPE_PAYMENT_INTERFACE_NAME and payment_intent_id is None: logger.debug(f'[{tag}] payment_intent_id not set, skipping.') return PipelineCommand.CONTINUE.value @@ -249,6 +249,7 @@ def run_filter( payment_intent_id, amount_in_cents, has_been_refunded, + psp, **kwargs ): # pylint: disable=arguments-differ """ @@ -256,14 +257,14 @@ def run_filter( Arguments: order_id (str): The identifier of the order. payment_intent_id (str): The Stripe PaymentIntent id to look up. - amount_in_cents (decimal): Total amount to refund + refund_amount (decimal): Total amount to refund has_been_refunded (bool): Has this payment been refunded kwargs: arguments passed through from the filter. """ tag = type(self).__name__ - if not payment_intent_id or not amount_in_cents: # pragma: no cover + if psp != EDX_STRIPE_PAYMENT_INTERFACE_NAME and not payment_intent_id or not amount_in_cents: # pragma: no cover logger.info(f'[{tag}] payment_intent_id or amount_in_cents not set, skipping.') return PipelineCommand.CONTINUE.value diff --git a/commerce_coordinator/settings/base.py b/commerce_coordinator/settings/base.py index 8bcec13aa..3d183c000 100644 --- a/commerce_coordinator/settings/base.py +++ b/commerce_coordinator/settings/base.py @@ -395,6 +395,7 @@ def root(*path_fragments): 'commerce_coordinator.apps.rollout.pipeline.DetermineActiveOrderManagementSystemByOrderID', 'commerce_coordinator.apps.commercetools.pipeline.FetchOrderDetailsByOrderID', 'commerce_coordinator.apps.stripe.pipeline.RefundPaymentIntent', + 'commerce_coordinator.apps.paypal.pipeline.RefundPayPalPayment', 'commerce_coordinator.apps.commercetools.pipeline.CreateReturnPaymentTransaction', 'commerce_coordinator.apps.commercetools.pipeline.UpdateCommercetoolsOrderReturnPaymentStatus', ] @@ -438,6 +439,8 @@ def root(*path_fragments): 'paypal': { 'user_activity_page_url': '', 'paypal_webhook_id': PAYPAL_WEBHOOK_ID, + 'client_id': '', + 'client_secret': '', }, }, } diff --git a/commerce_coordinator/settings/local.py b/commerce_coordinator/settings/local.py index a41afccd8..ff2d11745 100644 --- a/commerce_coordinator/settings/local.py +++ b/commerce_coordinator/settings/local.py @@ -147,6 +147,8 @@ 'paypal': { 'user_activity_page_url': 'https://sandbox.paypal.com/myaccount/activities/', 'paypal_webhook_id': 'SET-ME-PLEASE', + 'client_id': '', + 'client_secret': '', }, }, } diff --git a/requirements/base.txt b/requirements/base.txt index f1484a6b3..62ca05188 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -147,6 +147,8 @@ openedx-filters==1.11.0 # via -r requirements/base.in packaging==24.1 # via marshmallow +paypal-server-sdk==0.5.1 + # via -r requirements/base.in pbr==6.1.0 # via stevedore pillow==11.0.0