Skip to content

Commit

Permalink
feat: Added ios refund callback (#3967)
Browse files Browse the repository at this point in the history
  • Loading branch information
jawad-khan committed Jun 14, 2023
1 parent e084e2d commit 110d834
Show file tree
Hide file tree
Showing 10 changed files with 214 additions and 37 deletions.
Binary file added ecommerce/extensions/iap/api/v1/AppleRootCA-G3.cer
Binary file not shown.
3 changes: 3 additions & 0 deletions ecommerce/extensions/iap/api/v1/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
ERROR_TRANSACTION_NOT_FOUND_FOR_REFUND = "Could not find any transaction to refund for [%s] by processor [%s]"
ERROR_DURING_POST_ORDER_OP = "An error occurred during post order operations."
GOOGLE_PUBLISHER_API_SCOPE = "https://www.googleapis.com/auth/androidpublisher"
IGNORE_NON_REFUND_NOTIFICATION_FROM_APPLE = "Ignoring notification from apple since we are only expecting" \
" refund notifications"
LOGGER_BASKET_ALREADY_PURCHASED = "Basket creation failed for user [%s] with SKUS [%s]. Products already purchased"
LOGGER_BASKET_CREATED = "Basket created for user [%s] with SKUS [%s]"
LOGGER_BASKET_CREATION_FAILED = "Basket creation failed for user [%s]. Error: [%s]"
Expand All @@ -34,5 +36,6 @@
NO_PRODUCT_AVAILABLE = "No product is available to buy."
PRODUCTS_DO_NOT_EXIST = "Products with SKU(s) [{skus}] do not exist."
PRODUCT_IS_NOT_AVAILABLE = "Product [%s] is not available to buy."
RECEIVED_NOTIFICATION_FROM_APPLE = "Received notification from apple with notification type [%s]"
SEGMENT_MOBILE_BASKET_ADD = "Mobile Basket Add Items View Called"
SEGMENT_MOBILE_PURCHASE_VIEW = "Mobile Course Purchase View Called"
163 changes: 136 additions & 27 deletions ecommerce/extensions/iap/api/v1/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import urllib.error
import urllib.parse

import app_store_notifications_v2_validator as asn2
import ddt
import mock
import pytz
Expand Down Expand Up @@ -36,6 +37,7 @@
ERROR_ORDER_NOT_FOUND_FOR_REFUND,
ERROR_REFUND_NOT_COMPLETED,
ERROR_TRANSACTION_NOT_FOUND_FOR_REFUND,
IGNORE_NON_REFUND_NOTIFICATION_FROM_APPLE,
LOGGER_BASKET_ALREADY_PURCHASED,
LOGGER_BASKET_CREATED,
LOGGER_BASKET_CREATION_FAILED,
Expand All @@ -51,12 +53,13 @@
LOGGER_REFUND_SUCCESSFUL,
LOGGER_STARTING_PAYMENT_FLOW,
NO_PRODUCT_AVAILABLE,
PRODUCTS_DO_NOT_EXIST
PRODUCTS_DO_NOT_EXIST,
RECEIVED_NOTIFICATION_FROM_APPLE
)
from ecommerce.extensions.iap.api.v1.google_validator import GooglePlayValidator
from ecommerce.extensions.iap.api.v1.ios_validator import IOSValidator
from ecommerce.extensions.iap.api.v1.serializers import MobileOrderSerializer
from ecommerce.extensions.iap.api.v1.views import AndroidRefund, MobileCoursePurchaseExecutionView
from ecommerce.extensions.iap.api.v1.views import AndroidRefundView, MobileCoursePurchaseExecutionView
from ecommerce.extensions.iap.processors.android_iap import AndroidIAP
from ecommerce.extensions.iap.processors.ios_iap import IOSIAP
from ecommerce.extensions.order.utils import UserAlreadyPlacedOrder
Expand Down Expand Up @@ -671,7 +674,6 @@ def test_view_response(self):

class BaseRefundTests(RefundTestMixin, AccessTokenMixin, JwtMixin, TestCase):
MODEL_LOGGER_NAME = 'ecommerce.core.models'
path = reverse('iap:android-refund')

def setUp(self):
super(BaseRefundTests, self).setUp()
Expand All @@ -685,14 +687,13 @@ def setUp(self):
def assert_ok_response(self, response):
""" Assert the response has HTTP status 200 and no data. """
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.json(), [])

def test_transaction_id_not_found(self):
""" If the transaction id doesn't match, no refund IDs should be created. """
with LogCapture(self.logger_name) as logger:
AndroidRefund().refund(self.invalid_transaction_id, {})
AndroidRefundView().refund(self.invalid_transaction_id, {})
msg = ERROR_TRANSACTION_NOT_FOUND_FOR_REFUND % (self.invalid_transaction_id,
AndroidRefund.processor_name)
AndroidRefundView.processor_name)
logger.check((self.logger_name, 'ERROR', msg),)

@staticmethod
Expand All @@ -712,7 +713,6 @@ def assert_refund_and_order(self, refund, order, basket, processor_response, ref

self.assertEqual(basket, processor_response.basket)
self.assertEqual(refund_response.transaction_id, processor_response.transaction_id)
self.assertNotEqual(refund_response.id, processor_response.id)

def test_refund_completion_error(self):
"""
Expand All @@ -721,7 +721,7 @@ def test_refund_completion_error(self):
order = self.create_order()
PaymentProcessorResponse.objects.create(basket=order.basket,
transaction_id=self.valid_transaction_id,
processor_name=AndroidRefund.processor_name,
processor_name=AndroidRefundView.processor_name,
response=json.dumps({'state': 'approved'}))

def _revoke_lines(refund):
Expand All @@ -732,16 +732,16 @@ def _revoke_lines(refund):

with mock.patch.object(Refund, '_revoke_lines', side_effect=_revoke_lines, autospec=True):
refund_payload = {"state": "refund"}
msg = ERROR_REFUND_NOT_COMPLETED % (self.user.username, self.course_id, AndroidRefund.processor_name)
msg = ERROR_REFUND_NOT_COMPLETED % (self.user.username, self.course_id, AndroidRefundView.processor_name)

with LogCapture(self.logger_name) as logger:
AndroidRefund().refund(self.valid_transaction_id, refund_payload)
AndroidRefundView().refund(self.valid_transaction_id, refund_payload)
self.assertFalse(Refund.objects.exists())
self.assertEqual(len(PaymentProcessorResponse.objects.all()), 1)
# logger.check((self.logger_name, 'ERROR', msg),)

# A second call should ensure the atomicity of the refund logic
AndroidRefund().refund(self.valid_transaction_id, refund_payload)
AndroidRefundView().refund(self.valid_transaction_id, refund_payload)
self.assertFalse(Refund.objects.exists())
self.assertEqual(len(PaymentProcessorResponse.objects.all()), 1)
logger.check(
Expand All @@ -758,30 +758,29 @@ def test_valid_order(self):
self.assertFalse(Refund.objects.exists())
processor_response = PaymentProcessorResponse.objects.create(basket=basket,
transaction_id=self.valid_transaction_id,
processor_name=AndroidRefund.processor_name,
processor_name=AndroidRefundView.processor_name,
response=json.dumps({'state': 'approved'}))

with mock.patch.object(Refund, '_revoke_lines', side_effect=BaseRefundTests._revoke_lines, autospec=True):
refund_payload = {"state": "refund"}
AndroidRefund().refund(self.valid_transaction_id, refund_payload)
AndroidRefundView().refund(self.valid_transaction_id, refund_payload)
refund = Refund.objects.latest()
refund_response = PaymentProcessorResponse.objects.latest()

self.assert_refund_and_order(refund, order, basket, processor_response, refund_response)

# A second call should result in no additional refunds being created
with LogCapture(self.logger_name) as logger:
AndroidRefund().refund(self.valid_transaction_id, {})
msg = ERROR_ORDER_NOT_FOUND_FOR_REFUND % (self.valid_transaction_id, AndroidRefund.processor_name)
AndroidRefundView().refund(self.valid_transaction_id, {})
msg = ERROR_ORDER_NOT_FOUND_FOR_REFUND % (self.valid_transaction_id, AndroidRefundView.processor_name)
logger.check((self.logger_name, 'ERROR', msg),)


class AndroidRefundTests(BaseRefundTests):
MODEL_LOGGER_NAME = 'ecommerce.core.models'
path = reverse('iap:android-refund')
order_id_one = "1234"
order_id_two = "5678"
mock_android_response = {
mock_processor_response = {
"voidedPurchases": [
{
"purchaseToken": "purchase_token",
Expand All @@ -804,16 +803,12 @@ class AndroidRefundTests(BaseRefundTests):
]
}
logger_name = 'ecommerce.extensions.iap.api.v1.views'
processor_name = AndroidRefund.processor_name

def assert_ok_response(self, response):
""" Assert the response has HTTP status 200 and no data. """
self.assertEqual(response.status_code, status.HTTP_200_OK)
processor_name = AndroidIAP.NAME

def check_record_not_found_log(self, logger, msg_t):
response = self.client.get(self.path)
self.assert_ok_response(response)
refunds = self.mock_android_response['voidedPurchases']
refunds = self.mock_processor_response['voidedPurchases']
msgs = [msg_t % (refund['orderId'], self.processor_name) for refund in refunds]
logger.check(
(self.logger_name, 'ERROR', msgs[0]),
Expand All @@ -830,7 +825,7 @@ def test_transaction_id_not_found(self):

mock_credential_method.return_value.authorize.return_value = None
mock_build.return_value.purchases.return_value.voidedpurchases.return_value\
.list.return_value.execute.return_value = self.mock_android_response
.list.return_value.execute.return_value = self.mock_processor_response
self.check_record_not_found_log(logger, ERROR_TRANSACTION_NOT_FOUND_FOR_REFUND)

def test_valid_orders(self):
Expand All @@ -853,10 +848,10 @@ def test_valid_orders(self):

payment_processor_responses = []
for index in range(len(baskets)):
transaction_id = self.mock_android_response['voidedPurchases'][index]['orderId']
transaction_id = self.mock_processor_response['voidedPurchases'][index]['orderId']
payment_processor_responses.append(
PaymentProcessorResponse.objects.create(basket=baskets[0], transaction_id=transaction_id,
processor_name=AndroidRefund.processor_name,
processor_name=AndroidRefundView.processor_name,
response=json.dumps({'state': 'approved'})))

with mock.patch.object(Refund, '_revoke_lines', side_effect=BaseRefundTests._revoke_lines, autospec=True), \
Expand All @@ -867,7 +862,7 @@ def test_valid_orders(self):

mock_credential_method.return_value.authorize.return_value = None
mock_build.return_value.purchases.return_value.voidedpurchases.return_value.\
list.return_value.execute.return_value = self.mock_android_response
list.return_value.execute.return_value = self.mock_processor_response

response = self.client.get(self.path)
self.assert_ok_response(response)
Expand All @@ -894,3 +889,117 @@ def test_valid_orders(self):
# A second call should result in no additional refunds being created
with LogCapture(self.logger_name) as logger:
self.check_record_not_found_log(logger, ERROR_ORDER_NOT_FOUND_FOR_REFUND)


class IOSRefundTests(BaseRefundTests):
path = reverse('iap:ios-refund')
order_id_one = "1234"
mock_processor_response = {
"notificationType": "REFUND",
"notificationUUID": "3e16e420",
"data": {
"bundleId": "test.mobile",
"environment": "Sandbox",
"signedTransactionInfo": {
"originalTransactionId": "1234"
}
},
"version": "2.0",
"signedDate": 1679801012716
}
logger_name = 'ecommerce.extensions.iap.api.v1.views'
processor_name = IOSIAP.NAME

def assert_error_response(self, response):
""" Assert the response has HTTP status 200 and no data. """
self.assertEqual(response.status_code, status.HTTP_500_INTERNAL_SERVER_ERROR)

def check_record_not_found_log(self, logger, msg_t):
response = self.client.post(self.path)
self.assert_error_response(response)
transaction = self.mock_processor_response['data']['signedTransactionInfo']['originalTransactionId']
msg = msg_t % (transaction, self.processor_name)
info = RECEIVED_NOTIFICATION_FROM_APPLE % "REFUND"
logger.check(
(self.logger_name, "INFO", info),
(self.logger_name, 'ERROR', msg)
)

def test_transaction_id_not_found(self):
""" If the transaction id doesn't match, no refund IDs should be created. """

with mock.patch.object(asn2, 'parse') as mock_ios_response_parse, \
LogCapture(self.logger_name) as logger:

mock_ios_response_parse.return_value = self.mock_processor_response
self.check_record_not_found_log(logger, ERROR_TRANSACTION_NOT_FOUND_FOR_REFUND)

def test_valid_orders(self):
"""
View should create a refund if an order/line are found eligible for refund.
"""
order = self.create_order()
self.assertFalse(Refund.objects.exists())
basket = BasketFactory(site=self.site, owner=self.user)
basket.add_product(self.verified_product)

transaction_id = self.mock_processor_response['data']['signedTransactionInfo']['originalTransactionId']
processor_response = PaymentProcessorResponse.objects.create(basket=basket, transaction_id=transaction_id,
processor_name=self.processor_name,
response=json.dumps(self.mock_processor_response))

with mock.patch.object(Refund, '_revoke_lines', side_effect=BaseRefundTests._revoke_lines, autospec=True), \
mock.patch.object(asn2, 'parse') as mock_ios_response_parse, \
LogCapture(self.logger_name) as logger:

mock_ios_response_parse.return_value = self.mock_processor_response
response = self.client.post(self.path)
self.assert_ok_response(response)
logger.check(
(
self.logger_name,
'INFO',
RECEIVED_NOTIFICATION_FROM_APPLE % "REFUND"
),
(
self.logger_name,
'INFO',
LOGGER_REFUND_SUCCESSFUL % (self.order_id_one, self.processor_name)
),
)

refunds = Refund.objects.all()
refund_responses = PaymentProcessorResponse.objects.all()
self.assertEqual(len(refunds), 1)
self.assert_refund_and_order(refunds[0], order, basket,
processor_response, refund_responses[0])

# A second call should result in no additional refunds being created
with LogCapture(self.logger_name) as logger:
self.check_record_not_found_log(logger, ERROR_ORDER_NOT_FOUND_FOR_REFUND)

def test_non_refund_notification(self):
"""
View should create a refund if an order/line are found eligible for refund.
"""

with mock.patch.object(asn2, 'parse') as mock_ios_response_parse,\
LogCapture(self.logger_name) as logger:

non_refund_payload = self.mock_processor_response.copy()
non_refund_payload['notificationType'] = 'TEST'
mock_ios_response_parse.return_value = non_refund_payload
response = self.client.post(self.path)
self.assert_ok_response(response)
logger.check(
(
self.logger_name,
'INFO',
RECEIVED_NOTIFICATION_FROM_APPLE % "TEST"
),
(
self.logger_name,
'INFO',
IGNORE_NON_REFUND_NOTIFICATION_FROM_APPLE
)
)
6 changes: 4 additions & 2 deletions ecommerce/extensions/iap/api/v1/urls.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
from django.conf.urls import url

from ecommerce.extensions.iap.api.v1.views import (
AndroidRefund,
AndroidRefundView,
IOSRefundView,
MobileBasketAddItemsView,
MobileCheckoutView,
MobileCoursePurchaseExecutionView
Expand All @@ -11,5 +12,6 @@
url(r'^basket/add/$', MobileBasketAddItemsView.as_view(), name='mobile-basket-add'),
url(r'^checkout/$', MobileCheckoutView.as_view(), name='iap-checkout'),
url(r'^execute/$', MobileCoursePurchaseExecutionView.as_view(), name='iap-execute'),
url(r'^android/refund/$', AndroidRefund.as_view(), name='android-refund')
url(r'^android/refund/$', AndroidRefundView.as_view(), name='android-refund'),
url(r'^ios/refund/$', IOSRefundView.as_view(), name='ios-refund'),
]
Loading

0 comments on commit 110d834

Please sign in to comment.