diff --git a/commerce_coordinator/apps/commercetools/catalog_info/edx_utils.py b/commerce_coordinator/apps/commercetools/catalog_info/edx_utils.py index ed7a5edc6..97b2e1fba 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 @@ -71,7 +72,7 @@ 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 @@ -88,7 +89,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, psp = 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..d2d1cc368 100644 --- a/commerce_coordinator/apps/commercetools/pipeline.py +++ b/commerce_coordinator/apps/commercetools/pipeline.py @@ -12,7 +12,10 @@ 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, @@ -110,12 +113,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.intent_id } return ret_val @@ -156,22 +159,22 @@ 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.intent_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 - ret_val['amount_in_cents'] = get_edx_refund_amount(ct_order) + if payment: + ct_payment = ct_api_client.get_payment_by_key(payment.intent_id) + ret_val['refund_amount'] = 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['refund_amount'] = decimal.Decimal(0.00) ret_val['has_been_refunded'] = False ret_val['payment_data'] = None @@ -288,7 +291,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 +300,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 @@ -326,13 +330,17 @@ def run_filter( if payment_data is not None: payment_on_order = payment_data else: - payment_key = refund_response['payment_intent'] - payment_on_order = ct_api_client.get_payment_by_key(payment_key) + if psp == EDX_STRIPE_PAYMENT_INTERFACE_NAME: + payment_key = refund_response['payment_intent'] + payment_on_order = ct_api_client.get_payment_by_key(payment_key) + if 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 { diff --git a/commerce_coordinator/apps/paypal/clients.py b/commerce_coordinator/apps/paypal/clients.py new file mode 100644 index 000000000..1d8b5a6d9 --- /dev/null +++ b/commerce_coordinator/apps/paypal/clients.py @@ -0,0 +1,60 @@ +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}) + print("order", order) + + 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) + + paypal_capture_id = None + + #TODO will move this code in utils and reuse this for webhook case too + result = ApiHelper.json_serialize(result.body) + for link in result.get('refund_urls'): + if link.get("rel") == "up" and "captures" in link.get("href"): + paypal_capture_id = link.get("href").split("/")[-1] + break + + return {"paypal_capture_id": paypal_capture_id} diff --git a/commerce_coordinator/apps/paypal/pipeline.py b/commerce_coordinator/apps/paypal/pipeline.py index 9d4841d9c..8a6d0d6fd 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, + refund_amount, + 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 refund_amount: + 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..e832cb3cf 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 @@ -247,8 +247,9 @@ def run_filter( self, order_id, payment_intent_id, - amount_in_cents, + refund_amount, has_been_refunded, + psp, **kwargs ): # pylint: disable=arguments-differ """ @@ -263,7 +264,7 @@ def run_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 refund_amount: # pragma: no cover logger.info(f'[{tag}] payment_intent_id or amount_in_cents not set, skipping.') return PipelineCommand.CONTINUE.value @@ -278,7 +279,7 @@ def run_filter( try: ret_val = stripe_api_client.refund_payment_intent( payment_intent_id=payment_intent_id, - amount=amount_in_cents, + amount=refund_amount, order_uuid=order_id ) return { 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/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