Skip to content

Commit

Permalink
[SONIC-808] PayPal Webhooks implementation for refunds (#311)
Browse files Browse the repository at this point in the history
* fix: handle paypal payment recipt
handle paypal payment recipt url

SONIC-784

* fix: address review comments

* fix: handle paypal payment recipt
handle paypal payment recipt url

SONIC-784

* feat: Implement Paypal webhook refunds

* fix: fixes

* fix: fixes

* fix: fixes

* fix: fixes

* fix: fixes

* fix: fixes

* fix: fixes

* fix: fixes

* fix: fixes

* fix: fixes

* feat: Added tests for PayPal views file

* fix: fixes

* fix: fixes

* fix: fixes

* fix: fixes

* fix: fixes

* fix: fixes

* feat: removed unused fuction

* feat: added PayPal config in test.py

* feat: fixed CI failurs

* feat: fixed CI failures

* feat: fixed CI failures

* feat: added a comment

---------

Co-authored-by: mubbsharanwar <[email protected]>
Co-authored-by: Mohammad Ahtasham ul Hassan <[email protected]>
  • Loading branch information
3 people authored Dec 16, 2024
1 parent 007ac6f commit 9242856
Show file tree
Hide file tree
Showing 17 changed files with 541 additions and 174 deletions.
356 changes: 208 additions & 148 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 @@ -331,7 +331,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'), TransactionState.SUCCESS)


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
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 MagicMock, patch

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

0 comments on commit 9242856

Please sign in to comment.