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

feat: implement paypal webhook refunds #309

Merged
merged 19 commits into from
Dec 13, 2024
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,11 @@ def get_edx_payment_info(order: CTOrder):
return None, None


def get_edx_payment_service_provider(order: CTOrder) -> Union[str, None]:
for pr in order.payment_info.payments:
return pr.obj.payment_method_info.payment_interface


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 Down
360 changes: 210 additions & 150 deletions commerce_coordinator/apps/commercetools/clients.py

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion commerce_coordinator/apps/commercetools/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -340,7 +340,7 @@ def run_filter(
updated_payment = ct_api_client.create_return_payment_transaction(
payment_id=payment_on_order.id,
payment_version=payment_on_order.version,
stripe_refund=refund_response
refund=refund_response
)

return {
Expand Down
31 changes: 22 additions & 9 deletions commerce_coordinator/apps/commercetools/signals.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
"""
Commercetools signals and receivers.
"""

import logging

from commerce_coordinator.apps.commercetools.catalog_info.constants import TwoUKeys
from commerce_coordinator.apps.commercetools.tasks import (
refund_from_paypal_task,
refund_from_stripe_task,
update_line_item_state_on_fulfillment_completion
)
Expand All @@ -21,20 +23,20 @@ def fulfill_order_completed_send_line_item_state(**kwargs):
Update the line item state of the order placed in Commercetools based on LMS enrollment
"""

is_fulfilled = kwargs['is_fulfilled']
is_fulfilled = kwargs["is_fulfilled"]

if is_fulfilled:
to_state_key = TwoUKeys.SUCCESS_FULFILMENT_STATE
else:
to_state_key = TwoUKeys.FAILURE_FULFILMENT_STATE

result = update_line_item_state_on_fulfillment_completion(
order_id=kwargs['order_id'],
order_version=kwargs['order_version'],
line_item_id=kwargs['line_item_id'],
item_quantity=kwargs['item_quantity'],
from_state_id=kwargs['line_item_state_id'],
to_state_key=to_state_key
order_id=kwargs["order_id"],
order_version=kwargs["order_version"],
line_item_id=kwargs["line_item_id"],
item_quantity=kwargs["item_quantity"],
from_state_id=kwargs["line_item_state_id"],
to_state_key=to_state_key,
)

return result
Expand All @@ -46,7 +48,18 @@ def refund_from_stripe(**kwargs):
Create a refund transaction in Commercetools based on a refund created from the Stripe dashboard
"""
async_result = refund_from_stripe_task.delay(
payment_intent_id=kwargs['payment_intent_id'],
stripe_refund=kwargs['stripe_refund'],
payment_intent_id=kwargs["payment_intent_id"],
stripe_refund=kwargs["stripe_refund"],
)
return async_result.id


@log_receiver(logger)
def refund_from_paypal(**kwargs):
"""
Create a refund transaction in Commercetools based on a refund created from the PayPal dashboard
"""
async_result = refund_from_paypal_task.delay(
paypal_capture_id=kwargs["paypal_capture_id"], refund=kwargs["refund"]
)
return async_result.id
34 changes: 30 additions & 4 deletions commerce_coordinator/apps/commercetools/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
from commercetools import CommercetoolsError
from django.conf import settings

from commerce_coordinator.apps.commercetools.catalog_info.constants import EDX_PAYPAL_PAYMENT_INTERFACE_NAME

from .clients import CommercetoolsAPIClient
from .utils import has_full_refund_transaction

Expand Down Expand Up @@ -54,8 +56,6 @@ def refund_from_stripe_task(
Celery task for a refund registered in Stripe dashboard and need to create
refund payment transaction record via Commercetools API.
"""
# Celery serializes stripe_refund to a dict, so we need to explictly convert it back to a Refund object
stripe_refund = stripe.Refund.construct_from(stripe_refund, stripe.api_key)
client = CommercetoolsAPIClient()
try:
payment = client.get_payment_by_key(payment_intent_id)
Expand All @@ -66,11 +66,37 @@ def refund_from_stripe_task(
updated_payment = client.create_return_payment_transaction(
payment_id=payment.id,
payment_version=payment.version,
stripe_refund=stripe_refund
refund=stripe_refund
)
return updated_payment
except CommercetoolsError as err:
logger.error(f"Unable to create refund transaction for payment [ {payment.id} ] "
f"on Stripe refund {stripe_refund.id} "
f"on Stripe refund {stripe_refund['id']} "
f"with error {err.errors} and correlation id {err.correlation_id}")
return None


@shared_task(autoretry_for=(CommercetoolsError,), retry_kwargs={'max_retries': 5, 'countdown': 3})
def refund_from_paypal_task(
paypal_capture_id,
refund
):
"""
Celery task for a refund registered in PayPal dashboard and need to create
refund payment transaction record via Commercetools API.
"""
client = CommercetoolsAPIClient()
try:
payment = client.get_payment_by_transaction_interaction_id(paypal_capture_id)
updated_payment = client.create_return_payment_transaction(
payment_id=payment.id,
payment_version=payment.version,
refund=refund,
psp=EDX_PAYPAL_PAYMENT_INTERFACE_NAME,
)
return updated_payment
except CommercetoolsError as err:
logger.error(f"Unable to create refund transaction for payment {payment.key} "
f"on PayPal refund {refund.id} "
f"with error {err.errors} and correlation id {err.correlation_id}")
return None
4 changes: 2 additions & 2 deletions commerce_coordinator/apps/commercetools/tests/test_clients.py
Original file line number Diff line number Diff line change
Expand Up @@ -598,14 +598,14 @@ def test_create_refund_transaction_exception(self):
self.client_set.client.create_return_payment_transaction(
payment_id="mock_payment_id",
payment_version=1,
stripe_refund=mock_stripe_refund
refund=mock_stripe_refund
)

exception = cm.exception

expected_message = (
f"[CommercetoolsError] Unable to create refund payment transaction for "
f"payment mock_payment_id and stripe refund {mock_stripe_refund.id} "
f"payment mock_payment_id, refund {mock_stripe_refund.id} with PSP: stripe_edx "
f"- Correlation ID: {exception.correlation_id}, Details: {exception.errors}"
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ def test_correct_arguments_passed(self, mock_client):
mock_client().create_return_payment_transaction.assert_called_once_with(
payment_id=mock_payment.id,
payment_version=mock_payment.version,
stripe_refund=mock_stripe_refund
refund=mock_stripe_refund
)

def test_full_refund_already_exists(self, mock_client):
Expand Down
10 changes: 5 additions & 5 deletions commerce_coordinator/apps/commercetools/tests/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
has_refund_transaction,
send_order_confirmation_email,
send_unsupported_mode_fulfillment_error_email,
translate_stripe_refund_status_to_transaction_status
translate_refund_status_to_transaction_status
)


Expand Down Expand Up @@ -313,17 +313,17 @@ class TestTranslateStripeRefundStatus(unittest.TestCase):
"""

def test_translate_stripe_refund_status_succeeded(self):
self.assertEqual(translate_stripe_refund_status_to_transaction_status('succeeded'), TransactionState.SUCCESS)
self.assertEqual(translate_refund_status_to_transaction_status('succeeded'), TransactionState.SUCCESS)

def test_translate_stripe_refund_status_pending(self):
self.assertEqual(translate_stripe_refund_status_to_transaction_status('pending'), TransactionState.PENDING)
self.assertEqual(translate_refund_status_to_transaction_status('pending'), TransactionState.PENDING)

def test_translate_stripe_refund_status_failed(self):
self.assertEqual(translate_stripe_refund_status_to_transaction_status('failed'), TransactionState.FAILURE)
self.assertEqual(translate_refund_status_to_transaction_status('failed'), TransactionState.FAILURE)

def test_translate_stripe_refund_status_other(self):
# Test for an unknown status
self.assertEqual(translate_stripe_refund_status_to_transaction_status('unknown_status'), 'unknown_status')
self.assertEqual(translate_refund_status_to_transaction_status('unknown_status'), 'unknown_status')


class TestRetirementAnonymizingTestCase(unittest.TestCase):
Expand Down
8 changes: 5 additions & 3 deletions commerce_coordinator/apps/commercetools/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,16 +198,18 @@ def find_refund_transaction(payment: Payment, amount: decimal):
return {}


def translate_stripe_refund_status_to_transaction_status(stripe_refund_status: str):
def translate_refund_status_to_transaction_status(refund_status: str):
"""
Utility to translate stripe's refund object's status attribute to a valid CT transaction state
Utility to translate refund object's status attribute to a valid CT transaction state
"""
translations = {
'succeeded': TransactionState.SUCCESS,
'completed': TransactionState.SUCCESS,
'pending': TransactionState.PENDING,
'failed': TransactionState.FAILURE,
'canceled': TransactionState.FAILURE,
}
return translations.get(stripe_refund_status.lower(), stripe_refund_status)
return translations.get(refund_status.lower(), TransactionState.SUCCESS)


def _create_retired_hash_withsalt(value_to_retire, salt):
Expand Down
2 changes: 0 additions & 2 deletions commerce_coordinator/apps/paypal/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,5 +26,3 @@ def run_filter(self, psp, payment_intent_id, **params):
return {
'redirect_url': redirect_url,
}

return None
7 changes: 7 additions & 0 deletions commerce_coordinator/apps/paypal/signals.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
"""
Paypal signals and receivers.
"""

from commerce_coordinator.apps.core.signal_helpers import CoordinatorSignal

payment_refunded_signal = CoordinatorSignal()
100 changes: 100 additions & 0 deletions commerce_coordinator/apps/paypal/tests/test_views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
"""
Paypal views test cases
"""
import base64
import zlib
from unittest.mock import patch, MagicMock

from django.conf import settings
from django.urls import reverse
from rest_framework.test import APITestCase

from commerce_coordinator.apps.paypal.views import PayPalWebhookView


class PayPalWebhookViewTests(APITestCase):
""" Tests for PayPalWebhookView """

def setUp(self):
super().setUp()
self.url = reverse('paypal:paypal_webhook')
self.headers = {
'paypal-transmission-id': 'test-transmission-id',
'paypal-transmission-time': '2023-01-01T00:00:00Z',
'paypal-transmission-sig': base64.b64encode(b'test-signature').decode('utf-8'),
'paypal-cert-url': 'https://www.paypal.com/cert.pem',
}
self.body = b'test-body'
self.crc = zlib.crc32(self.body)
self.message = (
f"{self.headers['paypal-transmission-id']}|{self.headers['paypal-transmission-time']}|"
f"{settings.PAYPAL_WEBHOOK_ID}|{self.crc}"
)

@patch('requests.get')
@patch('commerce_coordinator.apps.paypal.views.x509.load_pem_x509_certificate')
def test_post_refund_event(self, mock_load_cert, mock_requests_get):
mock_requests_get.return_value.text = 'test-cert'
mock_load_cert.return_value.public_key.return_value.verify = MagicMock()

data = {
"event_type": "PAYMENT.CAPTURE.REFUNDED",
"resource": {
"id": "test-refund-id",
"create_time": "2023-01-01T00:00:00Z",
"status": "COMPLETED",
"amount": {
"value": "100.00",
"currency_code": "USD"
},
"invoice_id": "test-order-number",
"links": [
{"rel": "up", "href": "https://api.paypal.com/v2/payments/captures/test-capture-id"}
]
}
}

response = self.client.post(self.url, data, format='json', headers=self.headers)
self.assertEqual(response.status_code, 200)

@patch('requests.get')
@patch('commerce_coordinator.apps.paypal.views.x509.load_pem_x509_certificate')
def test_post_invalid_signature(self, mock_load_cert, mock_requests_get):
mock_requests_get.return_value.text = 'test-cert'
mock_load_cert.return_value.public_key.return_value.verify.side_effect = Exception("Invalid signature")

data = {
"event_type": "PAYMENT.CAPTURE.REFUNDED",
"resource": {}
}

response = self.client.post(self.url, data, format='json', headers=self.headers)
self.assertEqual(response.status_code, 400)

@patch('requests.get')
def test_get_certificate_from_url(self, mock_requests_get):
mock_requests_get.return_value.text = 'test-cert'
view = PayPalWebhookView()
cert = view._get_certificate(self.headers['paypal-cert-url']) # pylint: disable=protected-access
self.assertEqual(cert, 'test-cert')
mock_requests_get.assert_called_once_with(self.headers['paypal-cert-url'])

def test_is_valid_url(self):
view = PayPalWebhookView()
self.assertTrue(view._is_valid_url('https://www.paypal.com/cert.pem')) # pylint: disable=protected-access
self.assertFalse(view._is_valid_url('ftp://www.paypal.com/cert.pem')) # pylint: disable=protected-access
self.assertFalse(view._is_valid_url('https://www.untrusted.com/cert.pem')) # pylint: disable=protected-access

@patch('requests.get')
@patch('commerce_coordinator.apps.paypal.views.x509.load_pem_x509_certificate')
def test_invalid_event_type(self, mock_load_cert, mock_requests_get):
mock_requests_get.return_value.text = 'test-cert'
mock_load_cert.return_value.public_key.return_value.verify = MagicMock()

data = {
"event_type": "INVALID.EVENT.TYPE",
"resource": {}
}

response = self.client.post(self.url, data, format='json', headers=self.headers)
self.assertEqual(response.status_code, 200)
12 changes: 12 additions & 0 deletions commerce_coordinator/apps/paypal/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
"""
Paypal app urls
"""

from django.urls import path

from commerce_coordinator.apps.paypal.views import PayPalWebhookView

app_name = 'paypal'
urlpatterns = [
path('webhook/', PayPalWebhookView.as_view(), name='paypal_webhook'),
]
Loading
Loading