Skip to content

Commit

Permalink
Merge branch 'main' into sajjad/multiple-paypal-refund-transactions
Browse files Browse the repository at this point in the history
  • Loading branch information
syedsajjadkazmii authored Dec 19, 2024
2 parents 9e14286 + e48d83f commit ef2fe92
Show file tree
Hide file tree
Showing 20 changed files with 417 additions and 51 deletions.
37 changes: 15 additions & 22 deletions commerce_coordinator/apps/commercetools/catalog_info/edx_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
from commercetools.platform.models import TransactionType

from commerce_coordinator.apps.commercetools.catalog_info.constants import (
EDX_STRIPE_PAYMENT_INTERFACE_NAME,
PAYMENT_STATUS_INTERFACE_CODE_SUCCEEDED,
EdXFieldNames,
TwoUKeys
Expand Down Expand Up @@ -48,33 +47,24 @@ def get_edx_lms_user_name(customer: CTCustomer):
return customer.custom.fields[EdXFieldNames.LMS_USER_NAME]


def get_edx_successful_stripe_payment(order: CTOrder) -> Union[CTPayment, None]:
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.payment_method_info.payment_interface == EDX_STRIPE_PAYMENT_INTERFACE_NAME and \
pmt.interface_id:
return pmt
return None
if pmt.payment_status.interface_code == PAYMENT_STATUS_INTERFACE_CODE_SUCCEEDED and pmt.interface_id:
ct_payment_provider_id = pmt.payment_method_info.payment_interface
return pmt, ct_payment_provider_id
return None, None


# TODO update function and its return value name
# the return value could be either stripe payment intent id or PayPal order ID
def get_edx_payment_intent_id(order: CTOrder) -> Union[str, None]:
pmt = get_edx_successful_stripe_payment(order)
pmt, _ = get_edx_successful_payment_info(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.
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 None, 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 @@ -86,10 +76,13 @@ def get_edx_is_sanctioned(order: CTOrder) -> bool:
return get_edx_order_workflow_state_key(order) == TwoUKeys.SDN_SANCTIONED_ORDER_STATE


def get_edx_refund_amount(order: CTOrder) -> decimal:
def get_edx_refund_info(payment: CTPayment) -> decimal:
refund_amount = decimal.Decimal(0.00)
pmt = get_edx_successful_stripe_payment(order)
for transaction in pmt.transactions:
interaction_id = None
for transaction in payment.transactions:
if transaction.type == TransactionType.CHARGE: # pragma no cover
refund_amount += decimal.Decimal(typed_money_to_string(transaction.amount, money_as_decimal_string=True))
return refund_amount
interaction_id = transaction.interaction_id
return refund_amount, interaction_id

return refund_amount, interaction_id
62 changes: 43 additions & 19 deletions commerce_coordinator/apps/commercetools/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,12 @@
from openedx_filters.exceptions import OpenEdxFilterException
from requests import HTTPError

from commerce_coordinator.apps.commercetools.catalog_info.constants import (
EDX_PAYPAL_PAYMENT_INTERFACE_NAME,
EDX_STRIPE_PAYMENT_INTERFACE_NAME
)
from commerce_coordinator.apps.commercetools.catalog_info.edx_utils import (
get_edx_payment_intent_id,
get_edx_refund_amount,
get_edx_refund_info,
get_edx_successful_payment_info
)
from commerce_coordinator.apps.commercetools.clients import CommercetoolsAPIClient
Expand Down Expand Up @@ -109,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 if payment else None
}

return ret_val
Expand Down Expand Up @@ -155,22 +158,25 @@ 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
ret_val['amount_in_cents'] = get_edx_refund_amount(ct_order)
if payment:
ct_payment = ct_api_client.get_payment_by_key(payment.interface_id)
refund_amount, ct_transaction_interaction_id = get_edx_refund_info(ct_payment)
ret_val['amount_in_cents'] = refund_amount
ret_val['ct_transaction_interaction_id'] = ct_transaction_interaction_id
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['ct_transaction_interaction_id'] = None
ret_val['has_been_refunded'] = False
ret_val['payment_data'] = None

Expand Down Expand Up @@ -287,21 +293,23 @@ 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 PSP refund object on a refunded charge.
"""

def run_filter(
self,
refund_response,
active_order_management_system,
payment_data,
has_been_refunded,
payment_intent_id,
psp,
refund_response=None,
**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 @@ -322,30 +330,46 @@ def run_filter(

ct_api_client = CommercetoolsAPIClient()
try:
payment_on_order = None
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_key(payment_intent_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
error_message = "unknown"
if psp == EDX_STRIPE_PAYMENT_INTERFACE_NAME:
error_message = f"[stripe_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
error_message = "unknown"
if psp == EDX_STRIPE_PAYMENT_INTERFACE_NAME:
error_message = f"[stripe_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}] HTTP Error: {err}")
return PipelineCommand.CONTINUE.value
Expand Down
4 changes: 2 additions & 2 deletions commerce_coordinator/apps/commercetools/sub_messages/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -270,9 +270,9 @@ def _prepare_segment_event_properties(in_order, return_line_item_return_id):
lms_user_name = get_edx_lms_user_name(customer)
lms_user_id = get_edx_lms_user_id(customer)

logger.info(f'[CT-{tag}] calling stripe to refund payment intent {payment_intent_id}, message id: {message_id}')
logger.info(f'[CT-{tag}] calling PSP to refund payment "{payment_intent_id}", message id: {message_id}')

# Return payment if payment intent id is set
# Return payment if payment id is set
if payment_intent_id is not None:
result = OrderRefundRequested.run_filter(
order_id=order_id, return_line_item_return_id=return_line_item_return_id, message_id=message_id
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@
from unittest import TestCase
from unittest.mock import MagicMock, call, patch

from commercetools.platform.models import MoneyType as CTMoneyType
from commercetools.platform.models import Order as CTOrder
from commercetools.platform.models import ReturnInfo as CTReturnInfo
from commercetools.platform.models import ReturnPaymentState as CTReturnPaymentState
from commercetools.platform.models import TypedMoney as CTTypedMoney
from edx_django_utils.cache import TieredCache

from commerce_coordinator.apps.commercetools.clients import CommercetoolsAPIClient
Expand Down Expand Up @@ -252,6 +254,18 @@ def setUp(self):
self.mock.update_return_payment_state_after_successful_refund
}
)
# TODO: Properly mock the Payment object.
payment = self.mock.get_payment_by_key.return_value
amount = CTTypedMoney(
currency_code='USD',
cent_amount=1000,
type=CTMoneyType.CENT_PRECISION,
fraction_digits=2,
)
for transaction in payment.transactions:
transaction.amount = amount

self.payment_mock = payment

def tearDown(self):
MonkeyPatch.unmonkey(CommercetoolsAPIClient)
Expand All @@ -270,7 +284,6 @@ def unpack_for_uut(values):
def get_uut():
return fulfill_order_returned_uut

# todo this flow is broken
@patch('commerce_coordinator.apps.commercetools.sub_messages.tasks.is_edx_lms_order')
@patch('commerce_coordinator.apps.stripe.pipeline.StripeAPIClient')
def test_correct_arguments_passed_already_refunded_doest_break(self, _stripe_api_mock, _lms_signal):
Expand All @@ -279,7 +292,6 @@ def test_correct_arguments_passed_already_refunded_doest_break(self, _stripe_api
expected_data.
"""
mock_values = self.mock

ret_val = self.get_uut()(*self.unpack_for_uut(self.mock.example_payload))

self.assertTrue(ret_val)
Expand Down
13 changes: 10 additions & 3 deletions commerce_coordinator/apps/commercetools/tests/test_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from django.test import RequestFactory
from rest_framework.test import APITestCase

from commerce_coordinator.apps.commercetools.catalog_info.constants import EDX_STRIPE_PAYMENT_INTERFACE_NAME
from commerce_coordinator.apps.commercetools.constants import COMMERCETOOLS_ORDER_MANAGEMENT_SYSTEM
from commerce_coordinator.apps.commercetools.pipeline import (
AnonymizeRetiredUser,
Expand Down Expand Up @@ -146,7 +147,9 @@ 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,
payment_intent_id="pi_4MtwBwLkdIwGlenn28a3tqPa",
psp=EDX_STRIPE_PAYMENT_INTERFACE_NAME
)
mock_payment_result = ret['returned_payment']

Expand All @@ -167,7 +170,9 @@ 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,
payment_intent_id="pi_4MtwBwLkdIwGlenn28a3tqPa",
psp=EDX_STRIPE_PAYMENT_INTERFACE_NAME
)
mock_payment_result = ret['returned_payment']

Expand All @@ -184,7 +189,9 @@ 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,
payment_intent_id="pi_4MtwBwLkdIwGlenn28a3tqPa",
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
50 changes: 50 additions & 0 deletions commerce_coordinator/apps/paypal/clients.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
"""PayPal client"""
import json

from django.conf import settings
from paypalserversdk.api_helper import ApiHelper
from paypalserversdk.controllers.payments_controller import PaymentsController
from paypalserversdk.http.auth.o_auth_2 import ClientCredentialsAuthCredentials
from paypalserversdk.paypalserversdk_client import PaypalserversdkClient


class PayPalClient:
"""
PayPal SDK client to call PayPal APIs.
"""
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'],
),
)

def refund_order(self, capture_id):
"""
Capture PayPal refund.
Args:
capture_id (str): The identifier of the PayPal order to capture refund.
Returns:
The response from PayPal.
"""

paypal_client = self.paypal_client
payments_controller: PaymentsController = paypal_client.payments

collect = {"capture_id": capture_id, "prefer": "return=representation"}
refund_response = payments_controller.captures_refund(collect)

if refund_response.body:
response = json.loads(ApiHelper.json_serialize(refund_response.body))
return {
"id": response.get("id"),
"created": response.get("create_time"),
"status": response.get("status"),
"amount": response.get("amount").get("value"),
"currency": response.get("amount").get("currency_code"),
}

return None
Loading

0 comments on commit ef2fe92

Please sign in to comment.