Skip to content

Commit

Permalink
feat: Added Android refund api (#3922)
Browse files Browse the repository at this point in the history
* feat: Added Android refund api

Like Apple android doesn't have callback for every refund. Therefore we have created an endpoint  which we will hit daily through ecommerce worker.
Learner-9149
  • Loading branch information
jawad-khan authored and christopappas committed Dec 4, 2023
1 parent b3aa3ff commit 3e4ec38
Show file tree
Hide file tree
Showing 11 changed files with 386 additions and 8 deletions.
11 changes: 11 additions & 0 deletions ecommerce/extensions/iap/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from django.contrib import admin
from solo.admin import SingletonModelAdmin

from ecommerce.extensions.iap.models import IAPProcessorConfiguration, PaymentProcessorResponseExtension

admin.site.register(IAPProcessorConfiguration, SingletonModelAdmin)


@admin.register(PaymentProcessorResponseExtension)
class PaymentProcessorResponseExtensionAdmin(admin.ModelAdmin):
list_display = ('original_transaction_id', 'processor_response')
4 changes: 4 additions & 0 deletions ecommerce/extensions/iap/api/v1/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,12 @@
ERROR_BASKET_ID_NOT_PROVIDED = "Basket id is not provided"
ERROR_DURING_ORDER_CREATION = "An error occurred during order creation."
ERROR_DURING_PAYMENT_HANDLING = "An error occurred during payment handling."
ERROR_ORDER_NOT_FOUND_FOR_REFUND = "Could not find any order to refund for [%s] by processor [%s]"
ERROR_REFUND_NOT_COMPLETED = "Could not complete refund for user [%s] in course [%s] by processor [%s]"
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."
ERROR_WHILE_OBTAINING_BASKET_FOR_USER = "An unexpected exception occurred while obtaining basket for user [{}]."
GOOGLE_PUBLISHER_API_SCOPE = "https://www.googleapis.com/auth/androidpublisher"
LOGGER_BASKET_NOT_FOUND = "Basket [%s] not found."
LOGGER_PAYMENT_APPROVED = "Payment [%s] approved by payer [%s]"
LOGGER_PAYMENT_FAILED_FOR_BASKET = "Attempts to handle payment for basket [%d] failed."
Expand Down
9 changes: 9 additions & 0 deletions ecommerce/extensions/iap/api/v1/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
"""
Exceptions used by the iap v1 api.
"""


class RefundCompletionException(Exception):
"""
Exception if a refund is not approved
"""
228 changes: 226 additions & 2 deletions ecommerce/extensions/iap/api/v1/tests/test_views.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import datetime
import json
import urllib.error
import urllib.parse

Expand All @@ -8,17 +9,22 @@
from django.conf import settings
from django.test import override_settings
from django.urls import reverse
from oauth2client.service_account import ServiceAccountCredentials
from oscar.apps.order.exceptions import UnableToPlaceOrder
from oscar.apps.payment.exceptions import PaymentError
from oscar.core.loading import get_class, get_model
from oscar.test.factories import BasketFactory
from rest_framework import status
from testfixtures import LogCapture

from ecommerce.core.tests import toggle_switch
from ecommerce.coupons.tests.mixins import DiscoveryMockMixin
from ecommerce.courses.tests.factories import CourseFactory
from ecommerce.enterprise.tests.mixins import EnterpriseServiceMockMixin
from ecommerce.extensions.api.tests.test_authentication import AccessTokenMixin
from ecommerce.extensions.basket.constants import EMAIL_OPT_IN_ATTRIBUTE
from ecommerce.extensions.basket.tests.mixins import BasketMixin
from ecommerce.extensions.fulfillment.status import ORDER
from ecommerce.extensions.iap.api.v1.constants import (
COURSE_ALREADY_PAID_ON_DEVICE,
ERROR_ALREADY_PURCHASED,
Expand All @@ -27,6 +33,9 @@
ERROR_DURING_ORDER_CREATION,
ERROR_DURING_PAYMENT_HANDLING,
ERROR_DURING_POST_ORDER_OP,
ERROR_ORDER_NOT_FOUND_FOR_REFUND,
ERROR_REFUND_NOT_COMPLETED,
ERROR_TRANSACTION_NOT_FOUND_FOR_REFUND,
ERROR_WHILE_OBTAINING_BASKET_FOR_USER,
LOGGER_BASKET_NOT_FOUND,
LOGGER_PAYMENT_FAILED_FOR_BASKET,
Expand All @@ -36,15 +45,18 @@
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 MobileCoursePurchaseExecutionView
from ecommerce.extensions.iap.api.v1.views import AndroidRefund, 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
from ecommerce.extensions.payment.exceptions import RedundantPaymentNotificationError
from ecommerce.extensions.payment.models import PaymentProcessorResponse
from ecommerce.extensions.payment.tests.mixins import PaymentEventsMixin
from ecommerce.extensions.refund.status import REFUND, REFUND_LINE
from ecommerce.extensions.refund.tests.mixins import RefundTestMixin
from ecommerce.extensions.test.factories import create_basket, create_order
from ecommerce.tests.factories import ProductFactory, StockRecordFactory
from ecommerce.tests.mixins import LmsApiMockMixin
from ecommerce.tests.mixins import JwtMixin, LmsApiMockMixin
from ecommerce.tests.testcases import TestCase

Basket = get_model('basket', 'Basket')
Expand All @@ -57,6 +69,9 @@
Selector = get_class('partner.strategy', 'Selector')
StockRecord = get_model('partner', 'StockRecord')
Voucher = get_model('voucher', 'Voucher')
Option = get_model('catalogue', 'Option')
Refund = get_model('refund', 'Refund')
post_refund = get_class('refund.signals', 'post_refund')


@ddt.ddt
Expand Down Expand Up @@ -517,3 +532,212 @@ def test_view_response(self):
response_data = response.json()
self.assertIn(reverse('iap:iap-execute'), response_data['payment_page_url'])
self.assertEqual(response_data['payment_processor'], self.processor_name)


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

def setUp(self):
super(BaseRefundTests, self).setUp()
self.course_id = 'edX/DemoX/Demo_Course'
self.invalid_transaction_id = "invalid transaction"
self.valid_transaction_id = "123456"
self.entitlement_option = Option.objects.get(code='course_entitlement')
self.user = self.create_user()
self.logger_name = 'ecommerce.extensions.iap.api.v1.views'

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, {})
msg = ERROR_TRANSACTION_NOT_FOUND_FOR_REFUND % (self.invalid_transaction_id,
AndroidRefund.processor_name)
logger.check((self.logger_name, 'ERROR', msg),)

@staticmethod
def _revoke_lines(refund):
for line in refund.lines.all():
line.set_status(REFUND_LINE.COMPLETE)

refund.set_status(REFUND.COMPLETE)

def assert_refund_and_order(self, refund, order, basket, processor_response, refund_response):
""" Check if we refunded the correct order """
self.assertEqual(refund.order, order)
self.assertEqual(refund.user, order.user)
self.assertEqual(refund.status, 'Complete')
self.assertEqual(refund.total_credit_excl_tax, order.total_excl_tax)
self.assertEqual(refund.lines.count(), order.lines.count())

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):
"""
View should create a refund if an order/line are found eligible for refund.
"""
order = self.create_order()
PaymentProcessorResponse.objects.create(basket=order.basket,
transaction_id=self.valid_transaction_id,
processor_name=AndroidRefund.processor_name,
response=json.dumps({'state': 'approved'}))

def _revoke_lines(refund):
for line in refund.lines.all():
line.set_status(REFUND_LINE.COMPLETE)

refund.set_status(REFUND.REVOCATION_ERROR)

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)

with LogCapture(self.logger_name) as logger:
AndroidRefund().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)
self.assertFalse(Refund.objects.exists())
self.assertEqual(len(PaymentProcessorResponse.objects.all()), 1)
logger.check(
(self.logger_name, 'ERROR', msg),
(self.logger_name, 'ERROR', msg)
)

def test_valid_order(self):
"""
View should create a refund if an order/line are found eligible for refund.
"""
order = self.create_order()
basket = order.basket
self.assertFalse(Refund.objects.exists())
processor_response = PaymentProcessorResponse.objects.create(basket=basket,
transaction_id=self.valid_transaction_id,
processor_name=AndroidRefund.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)
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)
logger.check((self.logger_name, 'ERROR', msg),)


class AndroidRefundTests(BaseRefundTests):
MODEL_LOGGER_NAME = 'ecommerce.core.models'
path = reverse('iap:android-refund')
mock_android_response = {
"voidedPurchases": [
{
"purchaseToken": "purchase_token",
"purchaseTimeMillis": "1677275637963",
"voidedTimeMillis": "1677650787656",
"orderId": "1234",
"voidedSource": 1,
"voidedReason": 1,
"kind": "androidpublisher#voidedPurchase"
},
{
"purchaseToken": "purchase_token",
"purchaseTimeMillis": "1674131262110",
"voidedTimeMillis": "1677671872090",
"orderId": "5678",
"voidedSource": 0,
"voidedReason": 0,
"kind": "androidpublisher#voidedPurchase"
}
]
}

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)

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']
msgs = [msg_t % (refund['orderId'], AndroidRefund.processor_name) for refund in refunds]
logger.check(
(self.logger_name, 'ERROR', msgs[0]),
(self.logger_name, 'ERROR', msgs[1])
)

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

with mock.patch.object(ServiceAccountCredentials, 'from_json_keyfile_dict') as mock_credential_method, \
mock.patch('ecommerce.extensions.iap.api.v1.views.build') as mock_build, \
LogCapture(self.logger_name) as logger, \
mock.patch('httplib2.Http'):

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
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.
"""
orders = [self.create_order()]
self.assertFalse(Refund.objects.exists())
baskets = [BasketFactory(site=self.site, owner=self.user)]
baskets[0].add_product(self.verified_product)

second_course = CourseFactory(
id=u'edX/DemoX/Demo_Coursesecond', name=u'edX Demó Course second', partner=self.partner
)
second_verified_product = second_course.create_or_update_seat('verified', True, 10)
baskets.append(BasketFactory(site=self.site, owner=self.user))
baskets[1].add_product(second_verified_product)
orders.append(create_order(basket=baskets[1], user=self.user))
orders[1].status = ORDER.COMPLETE

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

with mock.patch.object(Refund, '_revoke_lines', side_effect=BaseRefundTests._revoke_lines, autospec=True), \
mock.patch.object(ServiceAccountCredentials, 'from_json_keyfile_dict') as mock_credential_method, \
mock.patch('ecommerce.extensions.iap.api.v1.views.build') as mock_build, \
mock.patch('httplib2.Http'):

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

response = self.client.get(self.path)
self.assert_ok_response(response)

refunds = Refund.objects.all()
refund_responses = PaymentProcessorResponse.objects.all().order_by('-id')[:1]
for index, _ in enumerate(refunds):
self.assert_refund_and_order(refunds[index], orders[index], baskets[index],
payment_processor_responses[index], refund_responses[index])

# 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)
2 changes: 2 additions & 0 deletions ecommerce/extensions/iap/api/v1/urls.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from django.conf.urls import url

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

0 comments on commit 3e4ec38

Please sign in to comment.