Skip to content

Commit 8fe5687

Browse files
committed
feat: add auto refund feature for paypal
1 parent 6892ab1 commit 8fe5687

File tree

9 files changed

+161
-35
lines changed

9 files changed

+161
-35
lines changed

commerce_coordinator/apps/commercetools/catalog_info/edx_utils.py

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
from commerce_coordinator.apps.commercetools.catalog_info.constants import (
1313
EDX_STRIPE_PAYMENT_INTERFACE_NAME,
14+
EDX_PAYPAL_PAYMENT_INTERFACE_NAME,
1415
PAYMENT_STATUS_INTERFACE_CODE_SUCCEEDED,
1516
EdXFieldNames,
1617
TwoUKeys
@@ -58,23 +59,22 @@ def get_edx_successful_stripe_payment(order: CTOrder) -> Union[CTPayment, None]:
5859
return None
5960

6061

61-
def get_edx_payment_intent_id(order: CTOrder) -> Union[str, None]:
62-
pmt = get_edx_successful_stripe_payment(order)
63-
if pmt:
64-
return pmt.interface_id
65-
return None
66-
67-
68-
# TODO update get_edx_successful_stripe_payment to accommodate this util logic
69-
# and replace it with that.
62+
# TODO remove get_edx_successful_stripe_payment if there is no more use.
7063
def get_edx_successful_payment_info(order: CTOrder):
7164
for pr in order.payment_info.payments:
7265
pmt = pr.obj
7366
if pmt.payment_status.interface_code == PAYMENT_STATUS_INTERFACE_CODE_SUCCEEDED and pmt.interface_id:
74-
return pmt.interface_id, pmt.payment_method_info.payment_interface
67+
return pmt, pmt.payment_method_info.payment_interface
7568
return None, None
7669

7770

71+
def get_edx_payment_intent_id(order: CTOrder) -> Union[str, None]:
72+
pmt, _ = get_edx_successful_payment_info(order)
73+
if pmt:
74+
return pmt.interface_id
75+
return None
76+
77+
7878
def get_edx_order_workflow_state_key(order: CTOrder) -> Optional[str]:
7979
order_workflow_state = None
8080
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:
8888

8989
def get_edx_refund_amount(order: CTOrder) -> decimal:
9090
refund_amount = decimal.Decimal(0.00)
91-
pmt = get_edx_successful_stripe_payment(order)
91+
pmt, _ = get_edx_successful_payment_info(order)
9292
for transaction in pmt.transactions:
9393
if transaction.type == TransactionType.CHARGE: # pragma no cover
9494
refund_amount += decimal.Decimal(typed_money_to_string(transaction.amount, money_as_decimal_string=True))

commerce_coordinator/apps/commercetools/pipeline.py

Lines changed: 29 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,11 @@
1212
from openedx_filters.exceptions import OpenEdxFilterException
1313
from requests import HTTPError
1414

15-
from commerce_coordinator.apps.commercetools.catalog_info.constants import EDX_STRIPE_PAYMENT_INTERFACE_NAME
15+
from commerce_coordinator.apps.commercetools.catalog_info.constants import (
16+
EDX_STRIPE_PAYMENT_INTERFACE_NAME,
17+
EDX_PAYPAL_PAYMENT_INTERFACE_NAME
18+
)
1619
from commerce_coordinator.apps.commercetools.catalog_info.edx_utils import (
17-
get_edx_payment_intent_id,
1820
get_edx_refund_amount,
1921
get_edx_successful_payment_info
2022
)
@@ -110,12 +112,12 @@ def run_filter(self, active_order_management_system, order_number, **kwargs): #
110112
duration = (datetime.now() - start_time).total_seconds()
111113
log.info(f"[Performance Check] get_order_by_number call took {duration} seconds")
112114

113-
intent_id, psp = get_edx_successful_payment_info(ct_order)
115+
payment, psp = get_edx_successful_payment_info(ct_order)
114116

115117
ret_val = {
116118
"order_data": ct_order,
117119
"psp": psp,
118-
"payment_intent_id": intent_id
120+
"payment_intent_id": payment.interface_id
119121
}
120122

121123
return ret_val
@@ -156,21 +158,21 @@ def run_filter(self, active_order_management_system, order_id, **kwargs): # pyl
156158
duration = (datetime.now() - start_time).total_seconds()
157159
log.info(f"[Performance Check] get_order_by_id call took {duration} seconds")
158160

161+
payment, psp = get_edx_successful_payment_info(ct_order)
162+
159163
ret_val = {
160164
"order_data": ct_order,
161-
"order_id": ct_order.id
165+
"order_id": ct_order.id,
166+
"psp": psp,
167+
"payment_intent_id": payment.interface_id
162168
}
163169

164-
intent_id = get_edx_payment_intent_id(ct_order)
165-
166-
if intent_id:
167-
ct_payment = ct_api_client.get_payment_by_key(intent_id)
168-
ret_val['payment_intent_id'] = intent_id
170+
if payment:
171+
ct_payment = ct_api_client.get_payment_by_key(payment.intent_id)
169172
ret_val['amount_in_cents'] = get_edx_refund_amount(ct_order)
170173
ret_val['has_been_refunded'] = has_refund_transaction(ct_payment)
171174
ret_val['payment_data'] = ct_payment
172175
else:
173-
ret_val['payment_intent_id'] = None
174176
ret_val['amount_in_cents'] = decimal.Decimal(0.00)
175177
ret_val['has_been_refunded'] = False
176178
ret_val['payment_data'] = None
@@ -288,7 +290,7 @@ def run_filter(
288290
class CreateReturnPaymentTransaction(PipelineStep):
289291
"""
290292
Creates a Transaction for a return payment of a Commercetools order
291-
based on Stripes refund object on a refunded charge.
293+
based on Stripes or PayPal refund object on a refunded charge.
292294
"""
293295

294296
def run_filter(
@@ -297,12 +299,13 @@ def run_filter(
297299
active_order_management_system,
298300
payment_data,
299301
has_been_refunded,
302+
psp,
300303
**kwargs
301304
): # pylint: disable=arguments-differ
302305
"""
303306
Execute a filter with the signature specified.
304307
Arguments:
305-
refund_response: Stripe refund object or str value "charge_already_refunded"
308+
refund_response: PSP refund object or str value "charge_already_refunded"
306309
active_order_management_system: The Active Order System
307310
payment_data: CT payment object attached to the refunded order
308311
has_been_refunded (bool): Has this payment been refunded
@@ -325,28 +328,37 @@ def run_filter(
325328
try:
326329
if payment_data is not None:
327330
payment_on_order = payment_data
328-
else:
331+
elif psp == EDX_STRIPE_PAYMENT_INTERFACE_NAME:
329332
payment_key = refund_response['payment_intent']
330333
payment_on_order = ct_api_client.get_payment_by_key(payment_key)
334+
elif psp == EDX_PAYPAL_PAYMENT_INTERFACE_NAME:
335+
payment_on_order = ct_api_client.get_payment_by_transaction_interaction_id(refund_response['paypal_capture_id'])
331336

332337
updated_payment = ct_api_client.create_return_payment_transaction(
333338
payment_id=payment_on_order.id,
334339
payment_version=payment_on_order.version,
335-
refund=refund_response
340+
refund=refund_response,
341+
psp=psp,
336342
)
337343

338344
return {
339345
'returned_payment': updated_payment
340346
}
341347
except CommercetoolsError as err: # pragma no cover
348+
if psp == EDX_STRIPE_PAYMENT_INTERFACE_NAME:
349+
error_message = f"[payment_intent_id: {refund_response['payment_intent']}, "
350+
elif psp == EDX_PAYPAL_PAYMENT_INTERFACE_NAME:
351+
error_message = f"[paypal_capture_id: {refund_response['paypal_capture_id']}, "
342352
log.info(f"[{tag}] Unsuccessful attempt to create refund payment transaction with details: "
343-
f"[stripe_payment_intent_id: {refund_response['payment_intent']}, "
353+
f"psp: {psp}, "
354+
f"{error_message}"
344355
f"payment_id: {payment_on_order.id}], message_id: {kwargs['message_id']}")
345356
log.exception(f"[{tag}] Commercetools Error: {err}, {err.errors}")
346357
return PipelineCommand.CONTINUE.value
347358
except HTTPError as err: # pragma no cover
348359
log.info(f"[{tag}] Unsuccessful attempt to create refund payment transaction with details: "
349-
f"[stripe_payment_intent_id: {refund_response['payment_intent']}, "
360+
f"psp: {psp}, "
361+
f"{error_message}"
350362
f"payment_id: {payment_on_order.id}], message_id: {kwargs['message_id']}")
351363
log.exception(f"[{tag}] HTTP Error: {err}")
352364
return PipelineCommand.CONTINUE.value

commerce_coordinator/apps/commercetools/tests/test_pipeline.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from unittest import TestCase
44
from unittest.mock import patch
55

6+
from commerce_coordinator.apps.commercetools.catalog_info.constants import EDX_STRIPE_PAYMENT_INTERFACE_NAME
67
from commercetools.platform.models import ReturnInfo, ReturnPaymentState, ReturnShipmentState, TransactionType
78
from django.contrib.auth import get_user_model
89
from django.test import RequestFactory
@@ -146,7 +147,8 @@ def test_commercetools_transaction_create(self, mock_returned_payment, mock_paym
146147
payment_data=self.mock_response_payment,
147148
refund_response={"payment_intent": "mock_payment_intent"},
148149
active_order_management_system=COMMERCETOOLS_ORDER_MANAGEMENT_SYSTEM,
149-
has_been_refunded=False
150+
has_been_refunded=False,
151+
psp=EDX_STRIPE_PAYMENT_INTERFACE_NAME
150152
)
151153
mock_payment_result = ret['returned_payment']
152154

@@ -167,7 +169,8 @@ def test_commercetools_transaction_create_no_payment_data(self, mock_returned_pa
167169
payment_data=None,
168170
refund_response={"payment_intent": "mock_payment_intent"},
169171
active_order_management_system=COMMERCETOOLS_ORDER_MANAGEMENT_SYSTEM,
170-
has_been_refunded=False
172+
has_been_refunded=False,
173+
psp=EDX_STRIPE_PAYMENT_INTERFACE_NAME
171174
)
172175
mock_payment_result = ret['returned_payment']
173176

@@ -184,7 +187,8 @@ def test_commercetools_transaction_create_has_refund(self, mock_logger, mock_has
184187
payment_data=self.mock_response_payment,
185188
refund_response="charge_already_refunded",
186189
active_order_management_system=COMMERCETOOLS_ORDER_MANAGEMENT_SYSTEM,
187-
has_been_refunded=True
190+
has_been_refunded=True,
191+
psp=EDX_STRIPE_PAYMENT_INTERFACE_NAME
188192
)
189193
mock_logger.assert_called_once_with('[CreateReturnPaymentTransaction] refund has already been processed, '
190194
'skipping refund payment transaction creation')
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import logging
2+
3+
from django.conf import settings
4+
from paypalserversdk.http.auth.o_auth_2 import ClientCredentialsAuthCredentials
5+
from paypalserversdk.logging.configuration.api_logging_configuration import (
6+
LoggingConfiguration,
7+
RequestLoggingConfiguration,
8+
ResponseLoggingConfiguration,
9+
)
10+
from paypalserversdk.paypalserversdk_client import PaypalserversdkClient
11+
from paypalserversdk.controllers.orders_controller import OrdersController
12+
from paypalserversdk.controllers.payments_controller import PaymentsController
13+
from paypalserversdk.api_helper import ApiHelper
14+
15+
16+
class PayPalClient:
17+
def __init__(self):
18+
self.paypal_client: PaypalserversdkClient = PaypalserversdkClient(
19+
client_credentials_auth_credentials=ClientCredentialsAuthCredentials(
20+
o_auth_client_id=settings.PAYMENT_PROCESSOR_CONFIG['edx']['paypal']['client_id'],
21+
o_auth_client_secret=settings.PAYMENT_PROCESSOR_CONFIG['edx']['paypal']['client_secret'],
22+
),
23+
logging_configuration=LoggingConfiguration(
24+
log_level=logging.INFO,
25+
# Disable masking of sensitive headers for Sandbox testing.
26+
# This should be set to True (the default if unset)in production.
27+
mask_sensitive_headers=False,
28+
request_logging_config=RequestLoggingConfiguration(
29+
log_headers=True, log_body=True
30+
),
31+
response_logging_config=ResponseLoggingConfiguration(
32+
log_headers=True, log_body=True
33+
),
34+
),
35+
)
36+
37+
38+
def refund_order(self, order_id):
39+
paypal_client = self.paypal_client
40+
orders_controller: OrdersController = paypal_client.orders
41+
payments_controller: PaymentsController = paypal_client.payments
42+
43+
order = orders_controller.orders_get({"id": order_id})
44+
45+
capture_id = order.body.purchase_units[0].payments.captures[0].id
46+
47+
collect = {"capture_id": capture_id, "prefer": "return=minimal"}
48+
result = payments_controller.captures_refund(collect)
49+
50+
if result.body:
51+
return {"paypal_capture_id": capture_id}
52+
53+
return None

commerce_coordinator/apps/paypal/pipeline.py

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,11 @@
66
from urllib.parse import urlencode
77

88
from django.conf import settings
9+
from commerce_coordinator.apps.paypal.clients import PayPalClient
10+
from commerce_coordinator.apps.core.constants import PipelineCommand
911
from openedx_filters import PipelineStep
1012

1113
from commerce_coordinator.apps.commercetools.catalog_info.constants import EDX_PAYPAL_PAYMENT_INTERFACE_NAME
12-
from commerce_coordinator.apps.core.constants import PipelineCommand
1314

1415
logger = logging.getLogger(__name__)
1516

@@ -30,3 +31,51 @@ def run_filter(self, psp=None, payment_intent_id=None, **params):
3031
return {
3132
'redirect_url': redirect_url,
3233
}
34+
35+
36+
class RefundPayPalPayment(PipelineStep):
37+
"""
38+
Refunds a PayPal payment
39+
"""
40+
41+
def run_filter(
42+
self,
43+
order_id,
44+
amount_in_cents,
45+
has_been_refunded,
46+
psp,
47+
**kwargs
48+
): # pylint: disable=arguments-differ
49+
"""
50+
Execute a filter with the signature specified.
51+
Arguments:
52+
order_id (str): The identifier of the order.
53+
amount_in_cents (decimal): Total amount to refund
54+
has_been_refunded (bool): Has this payment been refunded
55+
kwargs: arguments passed through from the filter.
56+
"""
57+
58+
tag = type(self).__name__
59+
60+
if psp != EDX_PAYPAL_PAYMENT_INTERFACE_NAME and not amount_in_cents:
61+
return PipelineCommand.CONTINUE.value
62+
63+
if has_been_refunded:
64+
logger.info(f'[{tag}] payment already refunded from psp: {psp}, skipping.')
65+
return {
66+
'refund_response': "charge_already_refunded"
67+
}
68+
69+
paypal_client = PayPalClient()
70+
try:
71+
paypal_refund_response = paypal_client.refund_order(order_id=order_id)
72+
return {
73+
'refund_response': paypal_refund_response
74+
}
75+
except Exception as ex:
76+
logger.info(f'[CT-{tag}] Unsuccessful PayPal refund with details:'
77+
f'[order_id: {order_id}'
78+
f'message_id: {kwargs["message_id"]}')
79+
raise Exception from ex
80+
81+

commerce_coordinator/apps/stripe/pipeline.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,7 @@ class GetPaymentIntentReceipt(PipelineStep):
218218
def run_filter(self, psp=None, payment_intent_id=None, **params):
219219
tag = type(self).__name__
220220

221-
if payment_intent_id is None:
221+
if psp == EDX_STRIPE_PAYMENT_INTERFACE_NAME and payment_intent_id is None:
222222
logger.debug(f'[{tag}] payment_intent_id not set, skipping.')
223223
return PipelineCommand.CONTINUE.value
224224

@@ -249,21 +249,22 @@ def run_filter(
249249
payment_intent_id,
250250
amount_in_cents,
251251
has_been_refunded,
252+
psp,
252253
**kwargs
253254
): # pylint: disable=arguments-differ
254255
"""
255256
Execute a filter with the signature specified.
256257
Arguments:
257258
order_id (str): The identifier of the order.
258259
payment_intent_id (str): The Stripe PaymentIntent id to look up.
259-
amount_in_cents (decimal): Total amount to refund
260+
refund_amount (decimal): Total amount to refund
260261
has_been_refunded (bool): Has this payment been refunded
261262
kwargs: arguments passed through from the filter.
262263
"""
263264

264265
tag = type(self).__name__
265266

266-
if not payment_intent_id or not amount_in_cents: # pragma: no cover
267+
if psp != EDX_STRIPE_PAYMENT_INTERFACE_NAME and not payment_intent_id or not amount_in_cents: # pragma: no cover
267268
logger.info(f'[{tag}] payment_intent_id or amount_in_cents not set, skipping.')
268269
return PipelineCommand.CONTINUE.value
269270

commerce_coordinator/settings/base.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,7 @@ def root(*path_fragments):
395395
'commerce_coordinator.apps.rollout.pipeline.DetermineActiveOrderManagementSystemByOrderID',
396396
'commerce_coordinator.apps.commercetools.pipeline.FetchOrderDetailsByOrderID',
397397
'commerce_coordinator.apps.stripe.pipeline.RefundPaymentIntent',
398+
'commerce_coordinator.apps.paypal.pipeline.RefundPayPalPayment',
398399
'commerce_coordinator.apps.commercetools.pipeline.CreateReturnPaymentTransaction',
399400
'commerce_coordinator.apps.commercetools.pipeline.UpdateCommercetoolsOrderReturnPaymentStatus',
400401
]
@@ -438,6 +439,8 @@ def root(*path_fragments):
438439
'paypal': {
439440
'user_activity_page_url': '',
440441
'paypal_webhook_id': PAYPAL_WEBHOOK_ID,
442+
'client_id': '',
443+
'client_secret': '',
441444
},
442445
},
443446
}

commerce_coordinator/settings/local.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,8 @@
147147
'paypal': {
148148
'user_activity_page_url': 'https://sandbox.paypal.com/myaccount/activities/',
149149
'paypal_webhook_id': 'SET-ME-PLEASE',
150+
'client_id': '',
151+
'client_secret': '',
150152
},
151153
},
152154
}

requirements/base.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,8 @@ openedx-filters==1.11.0
147147
# via -r requirements/base.in
148148
packaging==24.1
149149
# via marshmallow
150+
paypal-server-sdk==0.5.1
151+
# via -r requirements/base.in
150152
pbr==6.1.0
151153
# via stevedore
152154
pillow==11.0.0

0 commit comments

Comments
 (0)