Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

PayPal auto refund #310

Merged
merged 11 commits into from
Dec 19, 2024
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
@@ -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
@@ -48,33 +47,24 @@
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
mubbsharanwar marked this conversation as resolved.
Show resolved Hide resolved
def get_edx_payment_intent_id(order: CTOrder) -> Union[str, None]:
mubbsharanwar marked this conversation as resolved.
Show resolved Hide resolved
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
@@ -86,10 +76,13 @@
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

Check failure on line 88 in commerce_coordinator/apps/commercetools/catalog_info/edx_utils.py

GitHub Actions / tests (ubuntu-20.04, 3.12, django42)

Missing coverage

Missing coverage on line 88
62 changes: 43 additions & 19 deletions commerce_coordinator/apps/commercetools/pipeline.py
Original file line number Diff line number Diff line change
@@ -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
@@ -109,12 +112,12 @@
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
shafqatfarhan marked this conversation as resolved.
Show resolved Hide resolved
}

return ret_val
@@ -155,22 +158,25 @@
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

Check failure on line 179 in commerce_coordinator/apps/commercetools/pipeline.py

GitHub Actions / tests (ubuntu-20.04, 3.12, django42)

Missing coverage

Missing coverage on line 179
ret_val['has_been_refunded'] = False
ret_val['payment_data'] = None

@@ -287,21 +293,23 @@
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
@@ -322,30 +330,46 @@

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)

Check failure on line 340 in commerce_coordinator/apps/commercetools/pipeline.py

GitHub Actions / tests (ubuntu-20.04, 3.12, django42)

Missing coverage

Missing coverage on lines 339-340

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
4 changes: 2 additions & 2 deletions commerce_coordinator/apps/commercetools/sub_messages/tasks.py
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
@@ -252,6 +254,18 @@ def setUp(self):
self.mock.update_return_payment_state_after_successful_refund
}
)
# TODO: Properly mock the Payment object.
mubbsharanwar marked this conversation as resolved.
Show resolved Hide resolved
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)
@@ -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):
@@ -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)
13 changes: 10 additions & 3 deletions commerce_coordinator/apps/commercetools/tests/test_pipeline.py
Original file line number Diff line number Diff line change
@@ -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,
@@ -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']

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

@@ -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')
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(

Check failure on line 16 in commerce_coordinator/apps/paypal/clients.py

GitHub Actions / tests (ubuntu-20.04, 3.12, django42)

Missing coverage

Missing coverage on line 16
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

Check failure on line 50 in commerce_coordinator/apps/paypal/clients.py

GitHub Actions / tests (ubuntu-20.04, 3.12, django42)

Missing coverage

Missing coverage on lines 34-50
Loading