Skip to content

Commit

Permalink
feat: add auto refund feature for paypal
Browse files Browse the repository at this point in the history
  • Loading branch information
mubbsharanwar committed Dec 17, 2024
1 parent 6892ab1 commit 3063f3b
Show file tree
Hide file tree
Showing 9 changed files with 161 additions and 35 deletions.
22 changes: 11 additions & 11 deletions commerce_coordinator/apps/commercetools/catalog_info/edx_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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))
Expand Down
46 changes: 29 additions & 17 deletions commerce_coordinator/apps/commercetools/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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
Expand All @@ -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
Expand Down
10 changes: 7 additions & 3 deletions commerce_coordinator/apps/commercetools/tests/test_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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']

Expand All @@ -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']

Expand All @@ -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')
Expand Down
53 changes: 53 additions & 0 deletions commerce_coordinator/apps/paypal/clients.py
Original file line number Diff line number Diff line change
@@ -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
51 changes: 50 additions & 1 deletion commerce_coordinator/apps/paypal/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand All @@ -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


7 changes: 4 additions & 3 deletions commerce_coordinator/apps/stripe/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -249,21 +249,22 @@ def run_filter(
payment_intent_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.
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

Expand Down
3 changes: 3 additions & 0 deletions commerce_coordinator/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
]
Expand Down Expand Up @@ -438,6 +439,8 @@ def root(*path_fragments):
'paypal': {
'user_activity_page_url': '',
'paypal_webhook_id': PAYPAL_WEBHOOK_ID,
'client_id': '',
'client_secret': '',
},
},
}
Expand Down
2 changes: 2 additions & 0 deletions commerce_coordinator/settings/local.py
Original file line number Diff line number Diff line change
Expand Up @@ -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': '',
},
},
}
Expand Down
2 changes: 2 additions & 0 deletions requirements/base.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down

0 comments on commit 3063f3b

Please sign in to comment.