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 16, 2024
1 parent 6892ab1 commit 5734a07
Show file tree
Hide file tree
Showing 7 changed files with 148 additions and 24 deletions.
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 @@ -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


Expand All @@ -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))
Expand Down
42 changes: 25 additions & 17 deletions commerce_coordinator/apps/commercetools/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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(
Expand All @@ -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
Expand All @@ -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 {
Expand Down
60 changes: 60 additions & 0 deletions commerce_coordinator/apps/paypal/clients.py
Original file line number Diff line number Diff line change
@@ -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}
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,
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


9 changes: 5 additions & 4 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 @@ -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
"""
Expand All @@ -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

Expand All @@ -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 {
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 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 5734a07

Please sign in to comment.