Commit da31dd9b by Clinton Blackburn

Merge pull request #104 from edx/refund-payment-credit

Added Support for Processing Refunds
parents 17ff8dcc 9ee71083
"""Serializers for order and line item data.""" """Serializers for order and line item data."""
# pylint: disable=abstract-method # pylint: disable=abstract-method
from oscar.core.loading import get_model from oscar.core.loading import get_model
from rest_framework import serializers from rest_framework import serializers
from ecommerce.extensions.payment.constants import ISO_8601_FORMAT from ecommerce.extensions.payment.constants import ISO_8601_FORMAT
...@@ -11,6 +10,7 @@ Line = get_model('order', 'Line') ...@@ -11,6 +10,7 @@ Line = get_model('order', 'Line')
Order = get_model('order', 'Order') Order = get_model('order', 'Order')
Product = get_model('catalogue', 'Product') Product = get_model('catalogue', 'Product')
ProductAttributeValue = get_model('catalogue', 'ProductAttributeValue') ProductAttributeValue = get_model('catalogue', 'ProductAttributeValue')
Refund = get_model('refund', 'Refund')
class BillingAddressSerializer(serializers.ModelSerializer): class BillingAddressSerializer(serializers.ModelSerializer):
...@@ -74,3 +74,10 @@ class PaymentProcessorSerializer(serializers.Serializer): ...@@ -74,3 +74,10 @@ class PaymentProcessorSerializer(serializers.Serializer):
def to_representation(self, instance): def to_representation(self, instance):
""" Serialize instances as a string instead of a mapping object. """ """ Serialize instances as a string instead of a mapping object. """
return instance.NAME return instance.NAME
class RefundSerializer(serializers.ModelSerializer):
""" Serializer for Refund objects. """
class Meta(object):
model = Refund
...@@ -21,14 +21,14 @@ from rest_framework.throttling import UserRateThrottle ...@@ -21,14 +21,14 @@ from rest_framework.throttling import UserRateThrottle
from ecommerce.extensions.api import exceptions as api_exceptions from ecommerce.extensions.api import exceptions as api_exceptions
from ecommerce.extensions.api.constants import APIConstants as AC from ecommerce.extensions.api.constants import APIConstants as AC
from ecommerce.extensions.api.serializers import OrderSerializer from ecommerce.extensions.api.serializers import OrderSerializer, RefundSerializer
from ecommerce.extensions.api.tests.test_authentication import AccessTokenMixin, OAUTH2_PROVIDER_URL from ecommerce.extensions.api.tests.test_authentication import AccessTokenMixin, OAUTH2_PROVIDER_URL
from ecommerce.extensions.fulfillment.mixins import FulfillmentMixin from ecommerce.extensions.fulfillment.mixins import FulfillmentMixin
from ecommerce.extensions.fulfillment.status import LINE, ORDER from ecommerce.extensions.fulfillment.status import LINE, ORDER
from ecommerce.extensions.payment import exceptions as payment_exceptions from ecommerce.extensions.payment import exceptions as payment_exceptions
from ecommerce.extensions.payment.processors.cybersource import Cybersource from ecommerce.extensions.payment.processors.cybersource import Cybersource
from ecommerce.extensions.payment.tests.processors import DummyProcessor, AnotherDummyProcessor from ecommerce.extensions.payment.tests.processors import DummyProcessor, AnotherDummyProcessor
from ecommerce.extensions.refund.tests.factories import RefundLineFactory from ecommerce.extensions.refund.tests.factories import RefundLineFactory, RefundFactory
from ecommerce.extensions.refund.tests.test_api import RefundTestMixin from ecommerce.extensions.refund.tests.test_api import RefundTestMixin
from ecommerce.tests.mixins import UserMixin, ThrottlingMixin, BasketCreationMixin, JwtMixin from ecommerce.tests.mixins import UserMixin, ThrottlingMixin, BasketCreationMixin, JwtMixin
...@@ -373,7 +373,7 @@ class OrderFulfillViewTests(UserMixin, TestCase): ...@@ -373,7 +373,7 @@ class OrderFulfillViewTests(UserMixin, TestCase):
return self.client.put(self.url) return self.client.put(self.url)
@ddt.data('delete', 'get', 'post') @ddt.data('delete', 'get', 'post')
def test_post_required(self, method): def test_put_or_patch_required(self, method):
""" Verify that the view only responds to PUT and PATCH operations. """ """ Verify that the view only responds to PUT and PATCH operations. """
response = getattr(self.client, method)(self.url) response = getattr(self.client, method)(self.url)
self.assertEqual(405, response.status_code) self.assertEqual(405, response.status_code)
...@@ -624,3 +624,46 @@ class RefundCreateViewTests(RefundTestMixin, AccessTokenMixin, JwtMixin, UserMix ...@@ -624,3 +624,46 @@ class RefundCreateViewTests(RefundTestMixin, AccessTokenMixin, JwtMixin, UserMix
self.assert_ok_response(response) self.assert_ok_response(response)
self.assertEqual(Refund.objects.count(), 0) self.assertEqual(Refund.objects.count(), 0)
@ddt.ddt
class RefundProcessViewTests(UserMixin, TestCase):
def setUp(self):
super(RefundProcessViewTests, self).setUp()
self.user = self.create_user(is_staff=True)
self.client.login(username=self.user.username, password=self.password)
self.refund = RefundFactory(user=self.user)
def put(self, action):
data = '{{"action": "{}"}}'.format(action)
path = reverse('api:v2:refunds:process', kwargs={'pk': self.refund.id})
return self.client.put(path, data, JSON_CONTENT_TYPE)
def test_staff_only(self):
""" The view should only be accessible to staff users. """
user = self.create_user(is_staff=False)
self.client.login(username=user.username, password=self.password)
response = self.put('approve')
self.assertEqual(response.status_code, 403)
def test_invalid_action(self):
""" If the action is neither approve nor deny, the view should return HTTP 400. """
response = self.put('reject')
self.assertEqual(response.status_code, 400)
@ddt.data('approve', 'deny')
def test_success(self, action):
""" If the action succeeds, the view should return HTTP 200 and the serialized Refund. """
with mock.patch('ecommerce.extensions.refund.models.Refund.{}'.format(action), mock.Mock(return_value=True)):
response = self.put(action)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data, RefundSerializer(self.refund).data)
@ddt.data('approve', 'deny')
def test_failure(self, action):
""" If the action fails, the view should return HTTP 500 and the serialized Refund. """
with mock.patch('ecommerce.extensions.refund.models.Refund.{}'.format(action), mock.Mock(return_value=False)):
response = self.put(action)
self.assertEqual(response.status_code, 500)
self.assertEqual(response.data, RefundSerializer(self.refund).data)
...@@ -3,7 +3,6 @@ from django.views.decorators.cache import cache_page ...@@ -3,7 +3,6 @@ from django.views.decorators.cache import cache_page
from ecommerce.extensions.api.v2 import views from ecommerce.extensions.api.v2 import views
ORDER_NUMBER_PATTERN = r'(?P<number>[-\w]+)' ORDER_NUMBER_PATTERN = r'(?P<number>[-\w]+)'
BASKET_ID_PATTERN = r'(?P<basket_id>[\w]+)' BASKET_ID_PATTERN = r'(?P<basket_id>[\w]+)'
...@@ -40,6 +39,7 @@ PAYMENT_URLS = patterns( ...@@ -40,6 +39,7 @@ PAYMENT_URLS = patterns(
REFUND_URLS = patterns( REFUND_URLS = patterns(
'', '',
url(r'^$', views.RefundCreateView.as_view(), name='create'), url(r'^$', views.RefundCreateView.as_view(), name='create'),
url(r'^(?P<pk>[\d]+)/process/$', views.RefundProcessView.as_view(), name='process'),
) )
urlpatterns = patterns( urlpatterns = patterns(
......
...@@ -5,8 +5,9 @@ from django.conf import settings ...@@ -5,8 +5,9 @@ from django.conf import settings
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from oscar.core.loading import get_model from oscar.core.loading import get_model
from rest_framework import status from rest_framework import status
from rest_framework.exceptions import ParseError
from rest_framework.generics import CreateAPIView, RetrieveAPIView, ListAPIView, UpdateAPIView from rest_framework.generics import CreateAPIView, RetrieveAPIView, ListAPIView, UpdateAPIView
from rest_framework.permissions import IsAuthenticated, DjangoModelPermissions from rest_framework.permissions import IsAuthenticated, DjangoModelPermissions, IsAdminUser
from rest_framework.response import Response from rest_framework.response import Response
from ecommerce.extensions.api import data, exceptions as api_exceptions, serializers from ecommerce.extensions.api import data, exceptions as api_exceptions, serializers
...@@ -20,10 +21,10 @@ from ecommerce.extensions.payment.helpers import (get_processor_class, get_defau ...@@ -20,10 +21,10 @@ from ecommerce.extensions.payment.helpers import (get_processor_class, get_defau
get_processor_class_by_name) get_processor_class_by_name)
from ecommerce.extensions.refund.api import find_orders_associated_with_course, create_refunds from ecommerce.extensions.refund.api import find_orders_associated_with_course, create_refunds
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
Order = get_model('order', 'Order') Order = get_model('order', 'Order')
Refund = get_model('refund', 'Refund')
User = get_user_model() User = get_user_model()
...@@ -413,7 +414,7 @@ class RefundCreateView(CreateAPIView): ...@@ -413,7 +414,7 @@ class RefundCreateView(CreateAPIView):
raise BadRequestException('No course_id specified.') raise BadRequestException('No course_id specified.')
# We should always have a username value as long as CanActForUser is in place. # We should always have a username value as long as CanActForUser is in place.
if not username: # pragma: no cover if not username: # pragma: no cover
raise BadRequestException('No username specified.') raise BadRequestException('No username specified.')
try: try:
...@@ -435,3 +436,39 @@ class RefundCreateView(CreateAPIView): ...@@ -435,3 +436,39 @@ class RefundCreateView(CreateAPIView):
# Return HTTP 200 if we did NOT create refunds. # Return HTTP 200 if we did NOT create refunds.
return Response([], status=status.HTTP_200_OK) return Response([], status=status.HTTP_200_OK)
class RefundProcessView(UpdateAPIView):
"""
Process--approve or deny--refunds.
This view can be used to approve, or deny, a Refund. Under normal conditions, the view returns HTTP status 200
and a serialized Refund. In the event of an error, the view will still return a serialized Refund (to reflect any
changed statuses); however, HTTP status will be 500.
Only staff users are permitted to use this view.
"""
permission_classes = (IsAuthenticated, IsAdminUser,)
queryset = Refund.objects.all()
serializer_class = serializers.RefundSerializer
def update(self, request, *args, **kwargs):
APPROVE = 'approve'
DENY = 'deny'
action = request.data.get('action', '').lower()
if action not in (APPROVE, DENY):
raise ParseError('The action [{}] is not valid.'.format(action))
refund = self.get_object()
result = False
if action == APPROVE:
result = refund.approve()
elif action == DENY:
result = refund.deny()
http_status = status.HTTP_200_OK if result else status.HTTP_500_INTERNAL_SERVER_ERROR
serializer = self.get_serializer(refund)
return Response(serializer.data, status=http_status)
from oscar.core.loading import get_model
from oscar.test import factories
ProductClass = get_model('catalogue', 'ProductClass')
ProductAttribute = get_model('catalogue', 'ProductAttribute')
class CourseCatalogTestMixin(object):
@property
def seat_product_class(self):
defaults = {'requires_shipping': False, 'track_stock': False, 'name': 'Seat'}
product_class, created = ProductClass.objects.get_or_create(slug='seat', defaults=defaults)
if created:
factories.ProductAttributeFactory(code='certificate_type', product_class=product_class, type='text')
factories.ProductAttributeFactory(code='course_key', product_class=product_class, type='text')
return product_class
def create_course_seats(self, course_id, certificate_types):
title = 'Seat in {}'.format(course_id)
parent_product = factories.ProductFactory(structure='parent', title=title,
product_class=self.seat_product_class)
seats = {}
for certificate_type in certificate_types:
seat_title = '{title} with {type} certificate'.format(title=title, type=certificate_type)
seat = factories.ProductFactory(structure='child', title=seat_title, product_class=None,
parent=parent_product)
seat.attr.certificate_type = certificate_type
seat.attr.course_key = course_id
seat.save()
factories.StockRecordFactory(product=seat)
seats[certificate_type] = seat
return seats
...@@ -10,14 +10,18 @@ from selenium.webdriver.firefox.webdriver import WebDriver ...@@ -10,14 +10,18 @@ from selenium.webdriver.firefox.webdriver import WebDriver
from selenium.webdriver.support.wait import WebDriverWait from selenium.webdriver.support.wait import WebDriverWait
from ecommerce.extensions.refund.status import REFUND from ecommerce.extensions.refund.status import REFUND
from ecommerce.extensions.refund.tests.factories import RefundFactory from ecommerce.extensions.refund.tests.mixins import RefundTestMixin
Refund = get_model('refund', 'Refund') Refund = get_model('refund', 'Refund')
ALL_REFUND_STATUSES = (
REFUND.OPEN, REFUND.PAYMENT_REFUND_ERROR, REFUND.PAYMENT_REFUNDED, REFUND.REVOCATION_ERROR, REFUND.COMPLETE,
REFUND.DENIED
)
@ddt.ddt @ddt.ddt
class RefundAcceptanceTestMixin(object): class RefundAcceptanceTestMixin(RefundTestMixin):
@classmethod @classmethod
def setUpClass(cls): def setUpClass(cls):
cls.selenium = WebDriver() cls.selenium = WebDriver()
...@@ -31,7 +35,8 @@ class RefundAcceptanceTestMixin(object): ...@@ -31,7 +35,8 @@ class RefundAcceptanceTestMixin(object):
def setUp(self): def setUp(self):
super(RefundAcceptanceTestMixin, self).setUp() super(RefundAcceptanceTestMixin, self).setUp()
self.refund = RefundFactory() self.refund = self.create_refund()
self.approve_button_selector = '[data-refund-id="{}"] [data-decision="approve"]'.format(self.refund.id) self.approve_button_selector = '[data-refund-id="{}"] [data-decision="approve"]'.format(self.refund.id)
self.deny_button_selector = '[data-refund-id="{}"] [data-decision="deny"]'.format(self.refund.id) self.deny_button_selector = '[data-refund-id="{}"] [data-decision="deny"]'.format(self.refund.id)
...@@ -125,7 +130,7 @@ class RefundAcceptanceTestMixin(object): ...@@ -125,7 +130,7 @@ class RefundAcceptanceTestMixin(object):
# Verify that the refund's status is updated. # Verify that the refund's status is updated.
selector = 'tr[data-refund-id="{}"] .refund-status'.format(refund_id) selector = 'tr[data-refund-id="{}"] .refund-status'.format(refund_id)
status = self.selenium.find_element_by_css_selector(selector) status = self.selenium.find_element_by_css_selector(selector)
self.assertEqual(REFUND.ERROR, status.text) self.assertEqual('Error', status.text)
# Verify that an alert is displayed. # Verify that an alert is displayed.
self.assert_alert_displayed( self.assert_alert_displayed(
...@@ -134,7 +139,7 @@ class RefundAcceptanceTestMixin(object): ...@@ -134,7 +139,7 @@ class RefundAcceptanceTestMixin(object):
'Please try again, or contact the E-Commerce Development Team.'.format(refund_id=refund_id) 'Please try again, or contact the E-Commerce Development Team.'.format(refund_id=refund_id)
) )
@ddt.data(REFUND.OPEN, REFUND.DENIED, REFUND.ERROR, REFUND.COMPLETE) @ddt.data(*ALL_REFUND_STATUSES)
def test_button_configurations(self, status): def test_button_configurations(self, status):
""" """
Verify correct button configurations for different refund statuses. Verify correct button configurations for different refund statuses.
...@@ -165,6 +170,7 @@ class RefundAcceptanceTestMixin(object): ...@@ -165,6 +170,7 @@ class RefundAcceptanceTestMixin(object):
class RefundListViewTests(RefundAcceptanceTestMixin, LiveServerTestCase): class RefundListViewTests(RefundAcceptanceTestMixin, LiveServerTestCase):
"""Acceptance tests of the refund list view.""" """Acceptance tests of the refund list view."""
def setUp(self): def setUp(self):
super(RefundListViewTests, self).setUp() super(RefundListViewTests, self).setUp()
self.path = reverse('dashboard:refunds:list') self.path = reverse('dashboard:refunds:list')
...@@ -172,6 +178,7 @@ class RefundListViewTests(RefundAcceptanceTestMixin, LiveServerTestCase): ...@@ -172,6 +178,7 @@ class RefundListViewTests(RefundAcceptanceTestMixin, LiveServerTestCase):
class RefundDetailViewTests(RefundAcceptanceTestMixin, LiveServerTestCase): class RefundDetailViewTests(RefundAcceptanceTestMixin, LiveServerTestCase):
"""Acceptance tests of the refund detail view.""" """Acceptance tests of the refund detail view."""
def setUp(self): def setUp(self):
super(RefundDetailViewTests, self).setUp() super(RefundDetailViewTests, self).setUp()
self.path = reverse('dashboard:refunds:detail', args=[self.refund.id]) self.path = reverse('dashboard:refunds:detail', args=[self.refund.id])
...@@ -12,7 +12,7 @@ from django.utils import importlib ...@@ -12,7 +12,7 @@ from django.utils import importlib
from ecommerce.extensions.fulfillment import exceptions from ecommerce.extensions.fulfillment import exceptions
from ecommerce.extensions.fulfillment.status import ORDER, LINE from ecommerce.extensions.fulfillment.status import ORDER, LINE
from ecommerce.extensions.refund.status import REFUND_LINE
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
...@@ -40,7 +40,6 @@ def fulfill_order(order, lines): ...@@ -40,7 +40,6 @@ def fulfill_order(order, lines):
error_msg = "Order has a current status of [{status}] which cannot be fulfilled.".format(status=order.status) error_msg = "Order has a current status of [{status}] which cannot be fulfilled.".format(status=order.status)
logger.error(error_msg) logger.error(error_msg)
raise exceptions.IncorrectOrderStatusError(error_msg) raise exceptions.IncorrectOrderStatusError(error_msg)
modules = getattr(settings, 'FULFILLMENT_MODULES', {})
# Construct a dict of lines by their product type. # Construct a dict of lines by their product type.
line_items = list(lines.all()) line_items = list(lines.all())
...@@ -50,22 +49,21 @@ def fulfill_order(order, lines): ...@@ -50,22 +49,21 @@ def fulfill_order(order, lines):
# any of the lines in the order. Fulfill line items in the order they are designated by the configuration. # any of the lines in the order. Fulfill line items in the order they are designated by the configuration.
# Remaining line items should be marked with a fulfillment error since we have no configuration that # Remaining line items should be marked with a fulfillment error since we have no configuration that
# allows them to be fulfilled. # allows them to be fulfilled.
for cls_path in modules: for module_class in get_fulfillment_modules():
try: module = module_class()
module_path, _, name = cls_path.rpartition('.') supported_lines = module.get_supported_lines(line_items)
module = getattr(importlib.import_module(module_path), name) line_items = list(set(line_items) - set(supported_lines))
supported_lines = module().get_supported_lines(order, line_items) module.fulfill_product(order, supported_lines)
line_items = list(set(line_items) - set(supported_lines))
module().fulfill_product(order, supported_lines)
except (ImportError, ValueError, AttributeError):
logger.exception("Could not load module at [%s]", cls_path)
# Check to see if any line items in the order have not been accounted for by a FulfillmentModule # Check to see if any line items in the order have not been accounted for by a FulfillmentModule
# Any product does not line up with a module, we have to mark a fulfillment error. # Any product does not line up with a module, we have to mark a fulfillment error.
for line in line_items: for line in line_items:
product_type = line.product.product_class.name product_type = line.product.get_product_class().name
logger.error("Product Type [%s] in order does not have an associated Fulfillment Module", product_type) logger.error("Product Type [%s] does not have an associated Fulfillment Module. It cannot be fulfilled.",
product_type)
line.set_status(LINE.FULFILLMENT_CONFIGURATION_ERROR) line.set_status(LINE.FULFILLMENT_CONFIGURATION_ERROR)
except Exception: # pylint: disable=broad-except
logger.exception('An error occurred while fulfilling order [%s].', order.number)
finally: finally:
# Check if all lines are successful, or there were errors, and set the status of the Order. # Check if all lines are successful, or there were errors, and set the status of the Order.
order_status = ORDER.COMPLETE order_status = ORDER.COMPLETE
...@@ -77,3 +75,54 @@ def fulfill_order(order, lines): ...@@ -77,3 +75,54 @@ def fulfill_order(order, lines):
order.set_status(order_status) order.set_status(order_status)
logger.info("Finished fulfilling order [%s] with status [%s]", order.number, order.status) logger.info("Finished fulfilling order [%s] with status [%s]", order.number, order.status)
return order # pylint: disable=lost-exception return order # pylint: disable=lost-exception
def get_fulfillment_modules():
""" Retrieves all fulfillment modules declared in settings. """
module_paths = getattr(settings, 'FULFILLMENT_MODULES', [])
modules = []
for cls_path in module_paths:
try:
module_path, _, name = cls_path.rpartition('.')
module = getattr(importlib.import_module(module_path), name)
modules.append(module)
except (ImportError, ValueError, AttributeError):
logger.exception("Could not load module at [%s]", cls_path)
return modules
def get_fulfillment_modules_for_line(line):
"""
Returns a list of fulfillment modules that can fulfill the given Line.
Arguments
line (Line): Line to be considered for fulfillment.
"""
return [module for module in get_fulfillment_modules() if module().supports_line(line)]
def revoke_fulfillment_for_refund(refund):
"""
Revokes fulfillment for all lines in a refund.
Returns
Boolean: True, if revocation of all lines succeeded; otherwise, False.
"""
succeeded = True
# TODO (CCB): As our list of product types and fulfillment modules grows, this may become slow,
# and should be updated. Runtime is O(n^2).
for refund_line in refund.lines.all():
order_line = refund_line.order_line
modules = get_fulfillment_modules_for_line(order_line)
for module in modules:
if module().revoke_line(order_line):
refund_line.set_status(REFUND_LINE.COMPLETE)
else:
succeeded = False
refund_line.set_status(REFUND_LINE.REVOCATION_ERROR)
return succeeded
...@@ -8,14 +8,12 @@ import json ...@@ -8,14 +8,12 @@ import json
import logging import logging
from django.conf import settings from django.conf import settings
from oscar.apps.catalogue.models import ProductAttributeValue
from rest_framework import status from rest_framework import status
import requests import requests
from requests.exceptions import ConnectionError, Timeout from requests.exceptions import ConnectionError, Timeout
from ecommerce.extensions.fulfillment.status import LINE from ecommerce.extensions.fulfillment.status import LINE
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
...@@ -28,22 +26,30 @@ class BaseFulfillmentModule(object): # pragma: no cover ...@@ -28,22 +26,30 @@ class BaseFulfillmentModule(object): # pragma: no cover
__metaclass__ = abc.ABCMeta __metaclass__ = abc.ABCMeta
@abc.abstractmethod @abc.abstractmethod
def get_supported_lines(self, order, lines): def supports_line(self, line):
""" Return a list of supported lines in the order """
Returns True if the given Line can be fulfilled/revoked by this module.
Args:
line (Line): Line to be considered.
"""
raise NotImplementedError
@abc.abstractmethod
def get_supported_lines(self, lines):
""" Return a list of supported lines
Each Fulfillment Module is capable of fulfillment certain products. This function allows a preliminary Each Fulfillment Module is capable of fulfillment certain products. This function allows a preliminary
check of which lines could be supported by this Fulfillment Module. check of which lines could be supported by this Fulfillment Module.
By evaluating the order and the lines, this will return a list of all the lines in the order that By evaluating the lines, this will return a list of all the lines in the order that
can be fulfilled by this module. can be fulfilled by this module.
Args: Args:
order (Order): The Order associated with the lines to be fulfilled
lines (List of Lines): Order Lines, associated with purchased products in an Order. lines (List of Lines): Order Lines, associated with purchased products in an Order.
Returns: Returns:
A supported list of lines, unmodified. A supported list of lines, unmodified.
""" """
raise NotImplementedError("Line support method not implemented!") raise NotImplementedError("Line support method not implemented!")
...@@ -66,19 +72,14 @@ class BaseFulfillmentModule(object): # pragma: no cover ...@@ -66,19 +72,14 @@ class BaseFulfillmentModule(object): # pragma: no cover
raise NotImplementedError("Fulfillment method not implemented!") raise NotImplementedError("Fulfillment method not implemented!")
@abc.abstractmethod @abc.abstractmethod
def revoke_product(self, order, lines): def revoke_line(self, line):
""" Revokes the specified lines in the order. """ Revokes the specified line.
Iterates over the given lines and revokes the associated products, if possible. Reports success if the product
can be revoked, but may fail if the module cannot support revoking or process of revoking the product fails
due to underlying services.
Args: Args:
order (Order): The Order associated with the lines to be revoked. line (Line): Order Line to be revoked.
lines (List of Lines): Order Lines, associated with purchased products in an Order.
Returns: Returns:
The original set of lines, with new statuses set based on the success or failure of revoking the products. True, if the product is revoked; otherwise, False.
""" """
raise NotImplementedError("Revoke method not implemented!") raise NotImplementedError("Revoke method not implemented!")
...@@ -87,29 +88,36 @@ class EnrollmentFulfillmentModule(BaseFulfillmentModule): ...@@ -87,29 +88,36 @@ class EnrollmentFulfillmentModule(BaseFulfillmentModule):
""" Fulfillment Module for enrolling students after a product purchase. """ Fulfillment Module for enrolling students after a product purchase.
Allows the enrollment of a student via purchase of a 'seat'. Allows the enrollment of a student via purchase of a 'seat'.
""" """
def get_supported_lines(self, order, lines): def _post_to_enrollment_api(self, data):
enrollment_api_url = settings.ENROLLMENT_API_URL
timeout = settings.ENROLLMENT_FULFILLMENT_TIMEOUT
headers = {
'Content-Type': 'application/json',
'X-Edx-Api-Key': settings.EDX_API_KEY
}
return requests.post(enrollment_api_url, data=json.dumps(data), headers=headers, timeout=timeout)
def supports_line(self, line):
return line.product.get_product_class().name == 'Seat'
def get_supported_lines(self, lines):
""" Return a list of lines that can be fulfilled through enrollment. """ Return a list of lines that can be fulfilled through enrollment.
Check each line in the order to see if it is a "Seat". Seats are fulfilled by enrolling students Checks each line to determine if it is a "Seat". Seats are fulfilled by enrolling students
in a course, which is the sole functionality of this module. Any Seat product will be returned as in a course, which is the sole functionality of this module. Any Seat product will be returned as
a supported line in the order. a supported line.
Args: Args:
order (Order): The Order associated with the lines to be fulfilled
lines (List of Lines): Order Lines, associated with purchased products in an Order. lines (List of Lines): Order Lines, associated with purchased products in an Order.
Returns: Returns:
A supported list of unmodified lines associated with "Seat" products. A supported list of unmodified lines associated with "Seat" products.
""" """
supported_lines = [] return [line for line in lines if self.supports_line(line)]
for line in lines:
if line.product.get_product_class().name == 'Seat':
supported_lines.append(line)
return supported_lines
def fulfill_product(self, order, lines): def fulfill_product(self, order, lines):
""" Fulfills the purchase of a 'seat' by enrolling the associated student. """ Fulfills the purchase of a 'seat' by enrolling the associated student.
...@@ -132,18 +140,20 @@ class EnrollmentFulfillmentModule(BaseFulfillmentModule): ...@@ -132,18 +140,20 @@ class EnrollmentFulfillmentModule(BaseFulfillmentModule):
enrollment_api_url = getattr(settings, 'ENROLLMENT_API_URL', None) enrollment_api_url = getattr(settings, 'ENROLLMENT_API_URL', None)
api_key = getattr(settings, 'EDX_API_KEY', None) api_key = getattr(settings, 'EDX_API_KEY', None)
if not enrollment_api_url or not api_key: if not (enrollment_api_url and api_key):
logger.error( logger.error(
"ENROLLMENT_API_URL and EDX_API_KEY must be set to use the EnrollmentFulfillmentModule" 'ENROLLMENT_API_URL and EDX_API_KEY must be set to use the EnrollmentFulfillmentModule'
) )
for line in lines: for line in lines:
line.set_status(LINE.FULFILLMENT_CONFIGURATION_ERROR) line.set_status(LINE.FULFILLMENT_CONFIGURATION_ERROR)
return order, lines
for line in lines: for line in lines:
try: try:
certificate_type = line.product.attribute_values.get(attribute__name="certificate_type").value certificate_type = line.product.attr.certificate_type
course_key = line.product.attribute_values.get(attribute__name="course_key").value course_key = line.product.attr.course_key
except ProductAttributeValue.DoesNotExist: except AttributeError:
logger.error("Supported Seat Product does not have required attributes, [certificate_type, course_key]") logger.error("Supported Seat Product does not have required attributes, [certificate_type, course_key]")
line.set_status(LINE.FULFILLMENT_CONFIGURATION_ERROR) line.set_status(LINE.FULFILLMENT_CONFIGURATION_ERROR)
continue continue
...@@ -156,18 +166,8 @@ class EnrollmentFulfillmentModule(BaseFulfillmentModule): ...@@ -156,18 +166,8 @@ class EnrollmentFulfillmentModule(BaseFulfillmentModule):
} }
} }
headers = {
'Content-Type': 'application/json',
'X-Edx-Api-Key': api_key,
}
try: try:
response = requests.post( response = self._post_to_enrollment_api(data)
enrollment_api_url,
data=json.dumps(data),
headers=headers,
timeout=getattr(settings, 'ENROLLMENT_FULFILLMENT_TIMEOUT', 5)
)
if response.status_code == status.HTTP_200_OK: if response.status_code == status.HTTP_200_OK:
logger.info("Success fulfilling line [%d] of order [%s].", line.id, order.number) logger.info("Success fulfilling line [%d] of order [%s].", line.id, order.number)
...@@ -197,5 +197,28 @@ class EnrollmentFulfillmentModule(BaseFulfillmentModule): ...@@ -197,5 +197,28 @@ class EnrollmentFulfillmentModule(BaseFulfillmentModule):
logger.info("Finished fulfilling 'Seat' product types for order [%s]", order.number) logger.info("Finished fulfilling 'Seat' product types for order [%s]", order.number)
return order, lines return order, lines
def revoke_product(self, order, lines): def revoke_line(self, line):
raise NotImplementedError try:
logger.info('Attempting to revoke fulfillment of Line [%d]...', line.id)
data = {
'user': line.order.user.username,
'is_active': False,
'course_details': {
'course_id': line.product.attr.course_key
}
}
response = self._post_to_enrollment_api(data)
if response.status_code == status.HTTP_200_OK:
logger.info('Successfully revoked line [%d].', line.id)
return True
else:
data = response.json()
detail = data.get('message', '(No details provided.)')
logger.error('Failed to revoke fulfillment of Line [%d]: %s', line.id, detail)
except Exception: # pylint: disable=broad-except
logger.exception('Failed to revoke fulfillment of Line [%d].', line.id)
return False
...@@ -2,21 +2,27 @@ from ecommerce.extensions.fulfillment.modules import BaseFulfillmentModule ...@@ -2,21 +2,27 @@ from ecommerce.extensions.fulfillment.modules import BaseFulfillmentModule
from ecommerce.extensions.fulfillment.status import LINE from ecommerce.extensions.fulfillment.status import LINE
class MockFulFillmentModule(BaseFulfillmentModule): class MockFulfillmentModule(BaseFulfillmentModule):
def get_supported_lines(self, order, lines): def supports_line(self, line):
pass
def get_supported_lines(self, lines):
pass pass
def fulfill_product(self, order, lines): def fulfill_product(self, order, lines):
pass pass
def revoke_product(self, order, lines): def revoke_line(self, line):
pass pass
class FakeFulfillmentModule(MockFulFillmentModule): class FakeFulfillmentModule(MockFulfillmentModule):
"""Fake Fulfillment Module used to test the API without specific implementations.""" """Fake Fulfillment Module used to test the API without specific implementations."""
def get_supported_lines(self, order, lines): def supports_line(self, line):
return True
def get_supported_lines(self, lines):
"""Returns a list of lines this Fake module supposedly supports.""" """Returns a list of lines this Fake module supposedly supports."""
return lines return lines
...@@ -25,10 +31,33 @@ class FakeFulfillmentModule(MockFulFillmentModule): ...@@ -25,10 +31,33 @@ class FakeFulfillmentModule(MockFulFillmentModule):
for line in lines: for line in lines:
line.set_status(LINE.COMPLETE) line.set_status(LINE.COMPLETE)
def revoke_line(self, line):
""" Always revoke the product. """
return True
class FulfillmentNothingModule(MockFulFillmentModule):
class FulfillmentNothingModule(MockFulfillmentModule):
"""Fake Fulfillment Module that refuses to fulfill anything.""" """Fake Fulfillment Module that refuses to fulfill anything."""
def get_supported_lines(self, order, lines): def supports_line(self, line):
return False
def get_supported_lines(self, lines):
"""Returns an empty list, because this module supports nothing.""" """Returns an empty list, because this module supports nothing."""
return [] return []
class RevocationFailureModule(MockFulfillmentModule):
""" This module supports all Lines, but fulfills none. Use it to test revocation failures. """
def get_supported_lines(self, lines):
""" Returns the lines passed to indicate the module supports fulfilling all of them."""
return lines
def supports_line(self, line):
""" Returns True since the module supports fulfillment of all Lines."""
return True
def revoke_line(self, line):
""" Returns False to simulate a revocation failure."""
return False
...@@ -3,46 +3,99 @@ import ddt ...@@ -3,46 +3,99 @@ import ddt
from django.test import TestCase from django.test import TestCase
from django.test.utils import override_settings from django.test.utils import override_settings
from nose.tools import raises from nose.tools import raises
from testfixtures import LogCapture
from ecommerce.extensions.fulfillment import api, exceptions from ecommerce.extensions.fulfillment import api, exceptions
from ecommerce.extensions.fulfillment.api import get_fulfillment_modules, get_fulfillment_modules_for_line, \
revoke_fulfillment_for_refund
from ecommerce.extensions.fulfillment.status import ORDER, LINE from ecommerce.extensions.fulfillment.status import ORDER, LINE
from ecommerce.extensions.fulfillment.tests.mixins import FulfillmentTestMixin from ecommerce.extensions.fulfillment.tests.mixins import FulfillmentTestMixin
from ecommerce.extensions.fulfillment.tests.modules import FakeFulfillmentModule
from ecommerce.extensions.refund.status import REFUND, REFUND_LINE
from ecommerce.extensions.refund.tests.factories import RefundFactory
@ddt.ddt @ddt.ddt
class FulfillmentTest(FulfillmentTestMixin, TestCase): class FulfillmentApiTests(FulfillmentTestMixin, TestCase):
""" """ Tests for the fulfillment.api module. """
Test course seat fulfillment.
"""
def setUp(self): def setUp(self):
super(FulfillmentTest, self).setUp() super(FulfillmentApiTests, self).setUp()
self.order = self.generate_open_order() self.order = self.generate_open_order()
@override_settings(FULFILLMENT_MODULES=['ecommerce.extensions.fulfillment.tests.modules.FakeFulfillmentModule', ]) @override_settings(FULFILLMENT_MODULES=['ecommerce.extensions.fulfillment.tests.modules.FakeFulfillmentModule', ])
def test_successful_fulfillment(self): def test_fulfill_order_successful_fulfillment(self):
""" Test a successful fulfillment of an order. """ """ Test a successful fulfillment of an order. """
api.fulfill_order(self.order, self.order.lines) api.fulfill_order(self.order, self.order.lines)
self.assert_order_fulfilled(self.order) self.assert_order_fulfilled(self.order)
@override_settings(FULFILLMENT_MODULES=['ecommerce.extensions.fulfillment.tests.modules.FakeFulfillmentModule', ]) @override_settings(FULFILLMENT_MODULES=['ecommerce.extensions.fulfillment.tests.modules.FakeFulfillmentModule', ])
@raises(exceptions.IncorrectOrderStatusError) @raises(exceptions.IncorrectOrderStatusError)
def test_bad_fulfillment_state(self): def test_fulfill_order_bad_fulfillment_state(self):
"""Test a basic fulfillment of a Course Seat.""" """Test a basic fulfillment of a Course Seat."""
# Set the order to Refunded, which cannot be fulfilled. # Set the order to Refunded, which cannot be fulfilled.
self.order.set_status(ORDER.COMPLETE) self.order.set_status(ORDER.COMPLETE)
api.fulfill_order(self.order, self.order.lines) api.fulfill_order(self.order, self.order.lines)
@override_settings(FULFILLMENT_MODULES=['ecommerce.extensions.fulfillment.tests.modules.FulfillNothingModule', ]) @override_settings(FULFILLMENT_MODULES=['ecommerce.extensions.fulfillment.tests.modules.FulfillNothingModule', ])
def test_unknown_product_type(self): def test_fulfill_order_unknown_product_type(self):
"""Test an incorrect Fulfillment Module.""" """Test an incorrect Fulfillment Module."""
api.fulfill_order(self.order, self.order.lines) api.fulfill_order(self.order, self.order.lines)
self.assertEquals(ORDER.FULFILLMENT_ERROR, self.order.status) self.assertEquals(ORDER.FULFILLMENT_ERROR, self.order.status)
self.assertEquals(LINE.FULFILLMENT_CONFIGURATION_ERROR, self.order.lines.all()[0].status) self.assertEquals(LINE.FULFILLMENT_CONFIGURATION_ERROR, self.order.lines.all()[0].status)
@override_settings(FULFILLMENT_MODULES=['ecommerce.extensions.fulfillment.tests.modules.NotARealModule', ]) @override_settings(FULFILLMENT_MODULES=['ecommerce.extensions.fulfillment.tests.modules.NotARealModule', ])
def test_incorrect_module(self): def test_fulfill_order_incorrect_module(self):
"""Test an incorrect Fulfillment Module.""" """Test an incorrect Fulfillment Module."""
api.fulfill_order(self.order, self.order.lines) api.fulfill_order(self.order, self.order.lines)
self.assertEquals(ORDER.FULFILLMENT_ERROR, self.order.status) self.assertEquals(ORDER.FULFILLMENT_ERROR, self.order.status)
self.assertEquals(LINE.FULFILLMENT_CONFIGURATION_ERROR, self.order.lines.all()[0].status) self.assertEquals(LINE.FULFILLMENT_CONFIGURATION_ERROR, self.order.lines.all()[0].status)
@override_settings(FULFILLMENT_MODULES=['ecommerce.extensions.fulfillment.tests.modules.FakeFulfillmentModule',
'ecommerce.extensions.fulfillment.tests.modules.NotARealModule'])
def test_get_fulfillment_modules(self):
"""
Verify the function retrieves the modules specified in settings.
An error should be logged for modules that cannot be logged.
"""
logger_name = 'ecommerce.extensions.fulfillment.api'
with LogCapture(logger_name) as l:
actual = get_fulfillment_modules()
# Only FakeFulfillmentModule should be loaded since it is the only real class.
self.assertEqual(actual, [FakeFulfillmentModule])
# An error should be logged for NotARealModule since it cannot be loaded.
l.check((logger_name, 'ERROR',
'Could not load module at [ecommerce.extensions.fulfillment.tests.modules.NotARealModule]'))
@override_settings(FULFILLMENT_MODULES=['ecommerce.extensions.fulfillment.tests.modules.FakeFulfillmentModule',
'ecommerce.extensions.fulfillment.tests.modules.FulfillNothingModule'])
def test_get_fulfillment_modules_for_line(self):
"""
Verify the function returns an array of fulfillment modules that can fulfill a specific line.
"""
line = self.order.lines.first()
actual = get_fulfillment_modules_for_line(line)
self.assertEqual(actual, [FakeFulfillmentModule])
@override_settings(FULFILLMENT_MODULES=['ecommerce.extensions.fulfillment.tests.modules.FakeFulfillmentModule'])
def test_revoke_fulfillment_for_refund(self):
"""
Verify the function revokes fulfillment for all lines in a refund.
"""
refund = RefundFactory(status=REFUND.PAYMENT_REFUNDED)
self.assertTrue(revoke_fulfillment_for_refund(refund))
self.assertEqual(refund.status, REFUND.PAYMENT_REFUNDED)
self.assertEqual(set([line.status for line in refund.lines.all()]), {REFUND_LINE.COMPLETE})
@override_settings(FULFILLMENT_MODULES=['ecommerce.extensions.fulfillment.tests.modules.RevocationFailureModule'])
def test_revoke_fulfillment_for_refund_revocation_error(self):
"""
Verify the function sets the status of RefundLines and the Refund to "Revocation Error" if revocation fails.
"""
refund = RefundFactory(status=REFUND.PAYMENT_REFUNDED)
self.assertFalse(revoke_fulfillment_for_refund(refund))
self.assertEqual(refund.status, REFUND.PAYMENT_REFUNDED)
self.assertEqual(set([line.status for line in refund.lines.all()]), {REFUND_LINE.REVOCATION_ERROR})
"""Tests of the Fulfillment API's fulfillment modules.""" """Tests of the Fulfillment API's fulfillment modules."""
import json
import ddt import ddt
from django.conf import settings
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.test import TestCase, override_settings from django.test import TestCase, override_settings
from oscar.test.newfactories import UserFactory, BasketFactory import httpretty
import mock import mock
from nose.tools import raises from oscar.core.loading import get_model
from oscar.test import factories from oscar.test import factories
from requests import Response from oscar.test.newfactories import UserFactory, BasketFactory
from requests.exceptions import ConnectionError, Timeout from requests.exceptions import ConnectionError, Timeout
from rest_framework import status from testfixtures import LogCapture
from ecommerce.extensions.catalogue.tests.mixins import CourseCatalogTestMixin
from ecommerce.extensions.fulfillment.modules import EnrollmentFulfillmentModule from ecommerce.extensions.fulfillment.modules import EnrollmentFulfillmentModule
from ecommerce.extensions.fulfillment.status import LINE from ecommerce.extensions.fulfillment.status import LINE
from ecommerce.extensions.fulfillment.tests.mixins import FulfillmentTestMixin from ecommerce.extensions.fulfillment.tests.mixins import FulfillmentTestMixin
JSON = 'application/json'
ProductAttribute = get_model("catalogue", "ProductAttribute")
User = get_user_model() User = get_user_model()
@ddt.ddt @ddt.ddt
@override_settings(EDX_API_KEY='foo') @override_settings(EDX_API_KEY='foo')
class EnrollmentFulfillmentModuleTests(FulfillmentTestMixin, TestCase): class EnrollmentFulfillmentModuleTests(CourseCatalogTestMixin, FulfillmentTestMixin, TestCase):
"""Test course seat fulfillment.""" """Test course seat fulfillment."""
course_id = 'edX/DemoX/Demo_Course'
def setUp(self): def setUp(self):
user = UserFactory() user = UserFactory()
self.product_class = factories.ProductClassFactory(
name='Seat', requires_shipping=False, track_stock=False certificate_type = 'honor'
) seats = self.create_course_seats(self.course_id, (certificate_type,))
self.seat = seats[certificate_type]
self.course = factories.ProductFactory(
structure='parent', upc='001', title='EdX DemoX Course', product_class=self.product_class
)
self.seat = factories.ProductFactory(
structure='child',
upc='002',
title='Seat in EdX DemoX Course with Honor Certificate',
product_class=None,
parent=self.course
)
for stock_record in self.seat.stockrecords.all(): for stock_record in self.seat.stockrecords.all():
stock_record.price_currency = 'USD' stock_record.price_currency = 'USD'
stock_record.save() stock_record.save()
...@@ -49,17 +46,13 @@ class EnrollmentFulfillmentModuleTests(FulfillmentTestMixin, TestCase): ...@@ -49,17 +46,13 @@ class EnrollmentFulfillmentModuleTests(FulfillmentTestMixin, TestCase):
def test_enrollment_module_support(self): def test_enrollment_module_support(self):
"""Test that we get the correct values back for supported product lines.""" """Test that we get the correct values back for supported product lines."""
supported_lines = EnrollmentFulfillmentModule().get_supported_lines(self.order, list(self.order.lines.all())) supported_lines = EnrollmentFulfillmentModule().get_supported_lines(list(self.order.lines.all()))
self.assertEqual(1, len(supported_lines)) self.assertEqual(1, len(supported_lines))
@mock.patch('requests.post') @httpretty.activate
def test_enrollment_module_fulfill(self, mock_post_request): def test_enrollment_module_fulfill(self):
"""Happy path test to ensure we can properly fulfill enrollments.""" """Happy path test to ensure we can properly fulfill enrollments."""
fake_enrollment_api_response = Response() httpretty.register_uri(httpretty.POST, settings.ENROLLMENT_API_URL, status=200, body='{}', content_type=JSON)
fake_enrollment_api_response.status_code = status.HTTP_200_OK
mock_post_request.return_value = fake_enrollment_api_response
self._create_attributes()
# Attempt to enroll. # Attempt to enroll.
EnrollmentFulfillmentModule().fulfill_product(self.order, list(self.order.lines.all())) EnrollmentFulfillmentModule().fulfill_product(self.order, list(self.order.lines.all()))
...@@ -73,63 +66,81 @@ class EnrollmentFulfillmentModuleTests(FulfillmentTestMixin, TestCase): ...@@ -73,63 +66,81 @@ class EnrollmentFulfillmentModuleTests(FulfillmentTestMixin, TestCase):
def test_enrollment_module_fulfill_bad_attributes(self): def test_enrollment_module_fulfill_bad_attributes(self):
"""Test that use of the Fulfillment Module fails when the product does not have attributes.""" """Test that use of the Fulfillment Module fails when the product does not have attributes."""
# Attempt to enroll without creating the product attributes. ProductAttribute.objects.get(code='course_key').delete()
EnrollmentFulfillmentModule().fulfill_product(self.order, list(self.order.lines.all())) EnrollmentFulfillmentModule().fulfill_product(self.order, list(self.order.lines.all()))
self.assertEqual(LINE.FULFILLMENT_CONFIGURATION_ERROR, self.order.lines.all()[0].status) self.assertEqual(LINE.FULFILLMENT_CONFIGURATION_ERROR, self.order.lines.all()[0].status)
@mock.patch('requests.post', mock.Mock(side_effect=ConnectionError)) @mock.patch('requests.post', mock.Mock(side_effect=ConnectionError))
def test_enrollment_module_network_error(self): def test_enrollment_module_network_error(self):
"""Test that lines receive a network error status if a fulfillment request experiences a network error.""" """Test that lines receive a network error status if a fulfillment request experiences a network error."""
self._create_attributes()
EnrollmentFulfillmentModule().fulfill_product(self.order, list(self.order.lines.all())) EnrollmentFulfillmentModule().fulfill_product(self.order, list(self.order.lines.all()))
self.assertEqual(LINE.FULFILLMENT_NETWORK_ERROR, self.order.lines.all()[0].status) self.assertEqual(LINE.FULFILLMENT_NETWORK_ERROR, self.order.lines.all()[0].status)
@mock.patch('requests.post', mock.Mock(side_effect=Timeout)) @mock.patch('requests.post', mock.Mock(side_effect=Timeout))
def test_enrollment_module_request_timeout(self): def test_enrollment_module_request_timeout(self):
"""Test that lines receive a timeout error status if a fulfillment request times out.""" """Test that lines receive a timeout error status if a fulfillment request times out."""
self._create_attributes()
EnrollmentFulfillmentModule().fulfill_product(self.order, list(self.order.lines.all())) EnrollmentFulfillmentModule().fulfill_product(self.order, list(self.order.lines.all()))
self.assertEqual(LINE.FULFILLMENT_TIMEOUT_ERROR, self.order.lines.all()[0].status) self.assertEqual(LINE.FULFILLMENT_TIMEOUT_ERROR, self.order.lines.all()[0].status)
@httpretty.activate
@ddt.data(None, '{"message": "Oops!"}') @ddt.data(None, '{"message": "Oops!"}')
def test_enrollment_module_server_error(self, response_content): def test_enrollment_module_server_error(self, body):
"""Test that lines receive a server-side error status if a server-side error occurs during fulfillment.""" """Test that lines receive a server-side error status if a server-side error occurs during fulfillment."""
# NOTE: We are testing for cases where the response does and does NOT have data. The module should be able # NOTE: We are testing for cases where the response does and does NOT have data. The module should be able
# to handle both cases. # to handle both cases.
fake_error_response = Response() httpretty.register_uri(httpretty.POST, settings.ENROLLMENT_API_URL, status=500, body=body, content_type=JSON)
fake_error_response._content = response_content # pylint: disable=protected-access EnrollmentFulfillmentModule().fulfill_product(self.order, list(self.order.lines.all()))
fake_error_response.status_code = status.HTTP_500_INTERNAL_SERVER_ERROR self.assertEqual(LINE.FULFILLMENT_SERVER_ERROR, self.order.lines.all()[0].status)
with mock.patch('requests.post', return_value=fake_error_response): @httpretty.activate
self._create_attributes() def test_revoke_product(self):
""" The method should call the Enrollment API to un-enroll the student, and return True. """
# Attempt to enroll httpretty.register_uri(httpretty.POST, settings.ENROLLMENT_API_URL, status=200, body='{}', content_type=JSON)
EnrollmentFulfillmentModule().fulfill_product(self.order, list(self.order.lines.all())) line = self.order.lines.first()
self.assertEqual(LINE.FULFILLMENT_SERVER_ERROR, self.order.lines.all()[0].status) self.assertTrue(EnrollmentFulfillmentModule().revoke_line(line))
@raises(NotImplementedError) actual = json.loads(httpretty.last_request().body)
def test_enrollment_module_revoke(self): expected = {
"""Test that use of this method due to "not implemented" error.""" 'user': self.order.user.username,
EnrollmentFulfillmentModule().revoke_product(self.order, list(self.order.lines.all())) 'is_active': False,
'course_details': {
def _create_attributes(self): 'course_id': self.course_id
"""Create enrollment attributes and values for the Honor Seat in DemoX Course.""" }
certificate_type = factories.ProductAttributeFactory( }
name='certificate_type', product_class=self.product_class, type="text" self.assertEqual(actual, expected)
)
certificate_type.save() @httpretty.activate
def test_revoke_product_api_error(self):
course_key = factories.ProductAttributeFactory( """ If the Enrollment API responds with a non-200 status, the method should log an error and return False. """
name='course_key', product_class=self.product_class, type="text" message = 'Meh.'
) body = '{{"message": "{}"}}'.format(message)
course_key.save() httpretty.register_uri(httpretty.POST, settings.ENROLLMENT_API_URL, status=500, body=body, content_type=JSON)
certificate_value = factories.ProductAttributeValueFactory( line = self.order.lines.first()
attribute=certificate_type, product=self.seat, value_text='honor' logger_name = 'ecommerce.extensions.fulfillment.modules'
) with LogCapture(logger_name) as l:
certificate_value.save() self.assertFalse(EnrollmentFulfillmentModule().revoke_line(line))
l.check(
key_value = factories.ProductAttributeValueFactory( (logger_name, 'INFO', 'Attempting to revoke fulfillment of Line [{}]...'.format(line.id)),
attribute=course_key, product=self.seat, value_text='edX/DemoX/Demo_Course' (logger_name, 'ERROR', 'Failed to revoke fulfillment of Line [%d]: %s' % (line.id, message))
) )
key_value.save()
@httpretty.activate
def test_revoke_product_unknown_exception(self):
"""
If an exception is raised while contacting the Enrollment API, the method should log an error and return False.
"""
def request_callback(_method, _uri, _headers):
raise Timeout
httpretty.register_uri(httpretty.POST, settings.ENROLLMENT_API_URL, body=request_callback)
line = self.order.lines.first()
logger_name = 'ecommerce.extensions.fulfillment.modules'
with LogCapture(logger_name) as l:
self.assertFalse(EnrollmentFulfillmentModule().revoke_line(line))
l.check(
(logger_name, 'INFO', 'Attempting to revoke fulfillment of Line [{}]...'.format(line.id)),
(logger_name, 'ERROR', 'Failed to revoke fulfillment of Line [{}].'.format(line.id))
)
...@@ -75,3 +75,15 @@ class BasePaymentProcessor(object): # pragma: no cover ...@@ -75,3 +75,15 @@ class BasePaymentProcessor(object): # pragma: no cover
""" """
return PaymentProcessorResponse.objects.create(processor_name=self.NAME, transaction_id=transaction_id, return PaymentProcessorResponse.objects.create(processor_name=self.NAME, transaction_id=transaction_id,
response=response, basket=basket) response=response, basket=basket)
@abc.abstractmethod
def issue_credit(self, source, amount, currency):
"""
Issue a credit for the specified transaction.
Arguments:
source (Source): Payment Source used for the original debit/purchase.
amount (Decimal): amount to be credited/refunded
currency (string): currency of the amount to be credited
"""
raise NotImplementedError
""" CyberSource payment processing. """
import datetime import datetime
from decimal import Decimal from decimal import Decimal
import logging import logging
...@@ -6,14 +8,14 @@ import uuid ...@@ -6,14 +8,14 @@ import uuid
from django.conf import settings from django.conf import settings
from oscar.apps.payment.exceptions import UserCancelled, GatewayError, TransactionDeclined from oscar.apps.payment.exceptions import UserCancelled, GatewayError, TransactionDeclined
from oscar.core.loading import get_model from oscar.core.loading import get_model
from suds.client import Client
from suds.sudsobject import asdict
from suds.wsse import Security, UsernameToken
from ecommerce.extensions.order.constants import PaymentEventTypeName from ecommerce.extensions.order.constants import PaymentEventTypeName
from ecommerce.extensions.payment.constants import ISO_8601_FORMAT, CYBERSOURCE_CARD_TYPE_MAP from ecommerce.extensions.payment.constants import ISO_8601_FORMAT, CYBERSOURCE_CARD_TYPE_MAP
from ecommerce.extensions.payment.exceptions import ( from ecommerce.extensions.payment.exceptions import (InvalidSignatureError, InvalidCybersourceDecision,
InvalidSignatureError, PartialAuthorizationError)
InvalidCybersourceDecision,
PartialAuthorizationError,
)
from ecommerce.extensions.payment.helpers import sign from ecommerce.extensions.payment.helpers import sign
from ecommerce.extensions.payment.processors import BasePaymentProcessor from ecommerce.extensions.payment.processors import BasePaymentProcessor
...@@ -34,6 +36,7 @@ class Cybersource(BasePaymentProcessor): ...@@ -34,6 +36,7 @@ class Cybersource(BasePaymentProcessor):
For reference, see For reference, see
http://apps.cybersource.com/library/documentation/dev_guides/Secure_Acceptance_WM/Secure_Acceptance_WM.pdf. http://apps.cybersource.com/library/documentation/dev_guides/Secure_Acceptance_WM/Secure_Acceptance_WM.pdf.
""" """
NAME = u'cybersource' NAME = u'cybersource'
def __init__(self): def __init__(self):
...@@ -45,6 +48,9 @@ class Cybersource(BasePaymentProcessor): ...@@ -45,6 +48,9 @@ class Cybersource(BasePaymentProcessor):
AttributeError: If LANGUAGE_CODE setting is not set. AttributeError: If LANGUAGE_CODE setting is not set.
""" """
configuration = self.configuration configuration = self.configuration
self.soap_api_url = configuration['soap_api_url']
self.merchant_id = configuration['merchant_id']
self.transaction_key = configuration['transaction_key']
self.profile_id = configuration['profile_id'] self.profile_id = configuration['profile_id']
self.access_key = configuration['access_key'] self.access_key = configuration['access_key']
self.secret_key = configuration['secret_key'] self.secret_key = configuration['secret_key']
...@@ -112,8 +118,13 @@ class Cybersource(BasePaymentProcessor): ...@@ -112,8 +118,13 @@ class Cybersource(BasePaymentProcessor):
except ProductClass.DoesNotExist: except ProductClass.DoesNotExist:
# this occurs in test configurations where the seat product class is not in use # this occurs in test configurations where the seat product class is not in use
return None return None
line = basket.lines.filter(product__product_class=seat_class).first()
return line.product if line else None for line in basket.lines.all():
product = line.product
if product.get_product_class() == seat_class:
return product
return None
def handle_processor_response(self, response, basket=None): def handle_processor_response(self, response, basket=None):
""" """
...@@ -211,3 +222,70 @@ class Cybersource(BasePaymentProcessor): ...@@ -211,3 +222,70 @@ class Cybersource(BasePaymentProcessor):
def is_signature_valid(self, response): def is_signature_valid(self, response):
"""Returns a boolean indicating if the response's signature (indicating potential tampering) is valid.""" """Returns a boolean indicating if the response's signature (indicating potential tampering) is valid."""
return response and (self._generate_signature(response) == response.get(u'signature')) return response and (self._generate_signature(response) == response.get(u'signature'))
def issue_credit(self, source, amount, currency):
order = source.order
try:
order_request_token = source.reference
security = Security()
token = UsernameToken(self.merchant_id, self.transaction_key)
security.tokens.append(token)
client = Client(self.soap_api_url)
client.set_options(wsse=security)
credit_service = client.factory.create('ns0:CCCreditService')
credit_service._run = 'true' # pylint: disable=protected-access
credit_service.captureRequestID = source.reference
purchase_totals = client.factory.create('ns0:PurchaseTotals')
purchase_totals.currency = currency
purchase_totals.grandTotalAmount = unicode(amount)
response = client.service.runTransaction(merchantID=self.merchant_id, merchantReferenceCode=order.basket.id,
orderRequestToken=order_request_token,
ccCreditService=credit_service,
purchaseTotals=purchase_totals)
request_id = response.requestID
ppr = self.record_processor_response(suds_response_to_dict(response), transaction_id=request_id,
basket=order.basket)
except:
msg = 'An error occurred while attempting to issue a credit (via CyberSource) for order [{}].'.format(
order.number)
logger.exception(msg)
raise GatewayError(msg)
if response.decision == 'ACCEPT':
source.refund(amount, reference=request_id)
event_type, __ = PaymentEventType.objects.get_or_create(name=PaymentEventTypeName.REFUNDED)
PaymentEvent.objects.create(event_type=event_type, order=order, amount=amount, reference=request_id,
processor_name=self.NAME)
else:
raise GatewayError(
'Failed to issue CyberSource credit for order [{order_number}]. '
'Complete response has been recorded in entry [{response_id}]'.format(
order_number=order.number, response_id=ppr.id))
def suds_response_to_dict(d): # pragma: no cover
"""
Convert Suds object into serializable format.
Source: http://stackoverflow.com/a/15678861/592820
"""
out = {}
for k, v in asdict(d).iteritems():
if hasattr(v, '__keylist__'):
out[k] = suds_response_to_dict(v)
elif isinstance(v, list):
out[k] = []
for item in v:
if hasattr(item, '__keylist__'):
out[k].append(suds_response_to_dict(item))
else:
out[k].append(item)
else:
out[k] = v
return out
""" PayPal payment processing. """
from decimal import Decimal from decimal import Decimal
import logging import logging
from urlparse import urljoin from urlparse import urljoin
...@@ -16,6 +18,7 @@ logger = logging.getLogger(__name__) ...@@ -16,6 +18,7 @@ logger = logging.getLogger(__name__)
PaymentEvent = get_model('order', 'PaymentEvent') PaymentEvent = get_model('order', 'PaymentEvent')
PaymentEventType = get_model('order', 'PaymentEventType') PaymentEventType = get_model('order', 'PaymentEventType')
PaymentProcessorResponse = get_model('payment', 'PaymentProcessorResponse') PaymentProcessorResponse = get_model('payment', 'PaymentProcessorResponse')
ProductClass = get_model('catalogue', 'ProductClass')
Source = get_model('payment', 'Source') Source = get_model('payment', 'Source')
SourceType = get_model('payment', 'SourceType') SourceType = get_model('payment', 'SourceType')
...@@ -26,6 +29,7 @@ class Paypal(BasePaymentProcessor): ...@@ -26,6 +29,7 @@ class Paypal(BasePaymentProcessor):
For reference, see https://developer.paypal.com/docs/api/. For reference, see https://developer.paypal.com/docs/api/.
""" """
NAME = u'paypal' NAME = u'paypal'
def __init__(self): def __init__(self):
...@@ -164,7 +168,7 @@ class Paypal(BasePaymentProcessor): ...@@ -164,7 +168,7 @@ class Paypal(BasePaymentProcessor):
raise GatewayError raise GatewayError
entry = self.record_processor_response(payment.to_dict(), transaction_id=payment.id, basket=basket) self.record_processor_response(payment.to_dict(), transaction_id=payment.id, basket=basket)
logger.info(u"Successfully executed PayPal payment [%s] for basket [%d].", payment.id, basket.id) logger.info(u"Successfully executed PayPal payment [%s] for basket [%d].", payment.id, basket.id)
# Get or create Source used to track transactions related to PayPal # Get or create Source used to track transactions related to PayPal
...@@ -198,3 +202,62 @@ class Paypal(BasePaymentProcessor): ...@@ -198,3 +202,62 @@ class Paypal(BasePaymentProcessor):
attribute in this module. attribute in this module.
""" """
return payment.error # pragma: no cover return payment.error # pragma: no cover
def _get_payment_sale(self, payment):
"""
Returns the Sale related to a given Payment.
Note (CCB): We mostly expect to have a single sale and transaction per payment. If we
ever move to a split payment scenario, this will need to be updated.
"""
for transaction in payment.transactions:
for related_resource in transaction.related_resources:
try:
return related_resource.sale
except Exception: # pylint: disable=broad-except
continue
return None
def issue_credit(self, source, amount, currency):
order = source.order
try:
payment = paypalrestsdk.Payment.find(source.reference)
sale = self._get_payment_sale(payment)
if not sale:
logger.error('Unable to find a Sale associated with PayPal Payment [%s].', payment.id)
refund = sale.refund({
'amount': {
'total': unicode(amount),
'currency': currency,
}
})
except:
msg = 'An error occurred while attempting to issue a credit (via PayPal) for order [{}].'.format(
order.number)
logger.exception(msg)
raise GatewayError(msg)
basket = order.basket
if refund.success():
transaction_id = refund.id
self.record_processor_response(refund.to_dict(), transaction_id=transaction_id, basket=basket)
source.refund(amount, reference=transaction_id)
event_type, __ = PaymentEventType.objects.get_or_create(name=PaymentEventTypeName.REFUNDED)
PaymentEvent.objects.create(event_type=event_type, order=order, amount=amount, reference=transaction_id,
processor_name=self.NAME)
else:
error = refund.error
entry = self.record_processor_response(error, transaction_id=error['debug_id'], basket=basket)
msg = "Failed to refund PayPal payment [{sale_id}]. " \
"PayPal's response was recorded in entry [{response_id}].".format(sale_id=sale.id,
response_id=entry.id)
raise GatewayError(msg)
<?xml version="1.0" encoding="utf-8"?>
<wsdl:definitions xmlns:wsdl="http://schemas.xmlsoap.org/wsdl/" name="CyberSourceTransactionWS" targetNamespace="urn:schemas-cybersource-com:transaction-data:TransactionProcessor" xmlns:soap="http://schemas.xmlsoap.org/wsdl/soap/" xmlns:http="http://schemas.xmlsoap.org/wsdl/http/" xmlns:mime="http://schemas.xmlsoap.org/wsdl/mime/" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:tns="urn:schemas-cybersource-com:transaction-data:TransactionProcessor" xmlns:data="urn:schemas-cybersource-com:transaction-data-1.115">
<wsdl:types>
<xsd:schema>
<xsd:import namespace="urn:schemas-cybersource-com:transaction-data-1.115" schemaLocation="CyberSourceTransaction_1.115.xsd"/>
</xsd:schema>
</wsdl:types>
<wsdl:message name="messageIn">
<wsdl:part name="input" element="data:requestMessage"/>
</wsdl:message>
<wsdl:message name="messageOut">
<wsdl:part name="result" element="data:replyMessage"/>
</wsdl:message>
<wsdl:portType name="ITransactionProcessor">
<wsdl:operation name="runTransaction">
<wsdl:input name="inputMessageIn" message="tns:messageIn"/>
<wsdl:output name="outputMessageOut" message="tns:messageOut"/>
</wsdl:operation>
</wsdl:portType>
<wsdl:binding name="ITransactionProcessor" type="tns:ITransactionProcessor">
<soap:binding style="document" transport="http://schemas.xmlsoap.org/soap/http"/>
<wsdl:operation name="runTransaction">
<soap:operation soapAction="runTransaction" style="document"/>
<wsdl:input name="inputMessageIn">
<soap:body use="literal"/>
</wsdl:input>
<wsdl:output name="outputMessageOut">
<soap:body use="literal"/>
</wsdl:output>
</wsdl:operation>
</wsdl:binding>
<wsdl:service name="TransactionProcessor">
<wsdl:documentation>CyberSource Web Service</wsdl:documentation>
<wsdl:port name="portXML" binding="tns:ITransactionProcessor">
<soap:address location="https://ics2wstest.ic3.com/commerce/1.x/transactionProcessor"/>
</wsdl:port>
</wsdl:service>
</wsdl:definitions>
This source diff could not be displayed because it is too large. You can view the blob instead.
import json import json
import os
from urlparse import urljoin
from django.conf import settings from django.conf import settings
import httpretty import httpretty
import mock
from oscar.core.loading import get_model from oscar.core.loading import get_model
from suds.sudsobject import Factory
from ecommerce.extensions.payment.constants import CARD_TYPES from ecommerce.extensions.payment.constants import CARD_TYPES
from ecommerce.extensions.payment.helpers import sign from ecommerce.extensions.payment.helpers import sign
Order = get_model('order', 'Order') Order = get_model('order', 'Order')
PaymentProcessorResponse = get_model('payment', 'PaymentProcessorResponse') PaymentProcessorResponse = get_model('payment', 'PaymentProcessorResponse')
...@@ -112,6 +115,38 @@ class CybersourceMixin(object): ...@@ -112,6 +115,38 @@ class CybersourceMixin(object):
notification[u'signature'] = self.generate_signature(secret_key, notification) notification[u'signature'] = self.generate_signature(secret_key, notification)
return notification return notification
def mock_cybersource_wsdl(self):
files = ('CyberSourceTransaction_1.115.wsdl', 'CyberSourceTransaction_1.115.xsd')
for filename in files:
path = os.path.join(os.path.dirname(__file__), filename)
body = open(path, 'r').read()
url = urljoin(settings.PAYMENT_PROCESSOR_CONFIG['cybersource']['soap_api_url'], filename)
httpretty.register_uri(httpretty.GET, url, body=body)
def get_soap_mock(self, amount=100, currency='GBP', transaction_id=None, basket_id=None, decision='ACCEPT'):
class CybersourceSoapMock(mock.MagicMock):
def runTransaction(self, **kwargs): # pylint: disable=unused-argument
cc_reply_items = {
'reasonCode': 100,
'amount': unicode(amount),
'requestDateTime': '2015-01-01T:00:00:00Z',
'reconciliationID': 'efg456'
}
items = {
'requestID': transaction_id,
'decision': decision,
'merchantReferenceCode': unicode(basket_id),
'reasonCode': 100,
'requestToken': 'abc123',
'purchaseTotals': Factory.object('PurchaseTotals', {'currency': currency}),
'ccCreditReply': Factory.object('CCCreditReply', cc_reply_items)
}
return Factory.object('reply', items)
return CybersourceSoapMock
class PaypalMixin(object): class PaypalMixin(object):
"""Mixin with helper methods for mocking PayPal API responses.""" """Mixin with helper methods for mocking PayPal API responses."""
...@@ -180,7 +215,7 @@ class PaypalMixin(object): ...@@ -180,7 +215,7 @@ class PaypalMixin(object):
u'state': state, u'state': state,
u'transactions': [{ u'transactions': [{
u'amount': { u'amount': {
u'currency': u'USD', u'currency': u'GBP',
u'details': {u'subtotal': total}, u'details': {u'subtotal': total},
u'total': total u'total': total
}, },
...@@ -255,7 +290,7 @@ class PaypalMixin(object): ...@@ -255,7 +290,7 @@ class PaypalMixin(object):
u'state': state, u'state': state,
u'transactions': [{ u'transactions': [{
u'amount': { u'amount': {
u'currency': u'USD', u'currency': u'GBP',
u'details': {u'subtotal': total}, u'details': {u'subtotal': total},
u'total': total u'total': total
}, },
...@@ -274,7 +309,7 @@ class PaypalMixin(object): ...@@ -274,7 +309,7 @@ class PaypalMixin(object):
u'related_resources': [{ u'related_resources': [{
u'sale': { u'sale': {
u'amount': { u'amount': {
u'currency': u'USD', u'currency': u'GBP',
u'total': total u'total': total
}, },
u'create_time': u'2015-05-04T15:55:27Z', u'create_time': u'2015-05-04T15:55:27Z',
...@@ -306,7 +341,7 @@ class PaypalMixin(object): ...@@ -306,7 +341,7 @@ class PaypalMixin(object):
u'protection_eligibility_type': u'ITEM_NOT_RECEIVED_ELIGIBLE,UNAUTHORIZED_PAYMENT_ELIGIBLE', u'protection_eligibility_type': u'ITEM_NOT_RECEIVED_ELIGIBLE,UNAUTHORIZED_PAYMENT_ELIGIBLE',
u'state': u'completed', u'state': u'completed',
u'transaction_fee': { u'transaction_fee': {
u'currency': u'USD', u'currency': u'GBP',
u'value': u'0.50' u'value': u'0.50'
}, },
u'update_time': u'2015-05-04T15:58:47Z' u'update_time': u'2015-05-04T15:58:47Z'
......
...@@ -13,6 +13,9 @@ class DummyProcessor(BasePaymentProcessor): ...@@ -13,6 +13,9 @@ class DummyProcessor(BasePaymentProcessor):
def is_signature_valid(self, response): def is_signature_valid(self, response):
pass pass
def issue_credit(self, transaction_id, amount, currency):
pass
class AnotherDummyProcessor(DummyProcessor): class AnotherDummyProcessor(DummyProcessor):
NAME = 'another-dummy' NAME = 'another-dummy'
...@@ -19,7 +19,6 @@ from ecommerce.extensions.payment.processors.paypal import Paypal ...@@ -19,7 +19,6 @@ from ecommerce.extensions.payment.processors.paypal import Paypal
from ecommerce.extensions.payment.tests.mixins import PaymentEventsMixin, CybersourceMixin, PaypalMixin from ecommerce.extensions.payment.tests.mixins import PaymentEventsMixin, CybersourceMixin, PaypalMixin
from ecommerce.extensions.payment.views import CybersourceNotifyView, PaypalPaymentExecutionView from ecommerce.extensions.payment.views import CybersourceNotifyView, PaypalPaymentExecutionView
Basket = get_model('basket', 'Basket') Basket = get_model('basket', 'Basket')
Order = get_model('order', 'Order') Order = get_model('order', 'Order')
PaymentEvent = get_model('order', 'PaymentEvent') PaymentEvent = get_model('order', 'PaymentEvent')
...@@ -220,9 +219,8 @@ class PaypalPaymentExecutionViewTests(PaypalMixin, PaymentEventsMixin, TestCase) ...@@ -220,9 +219,8 @@ class PaypalPaymentExecutionViewTests(PaypalMixin, PaymentEventsMixin, TestCase)
Verify that a user who has approved payment is redirected to the configured receipt page when payment Verify that a user who has approved payment is redirected to the configured receipt page when payment
execution fails. execution fails.
""" """
with mock.patch.object( with mock.patch.object(PaypalPaymentExecutionView, 'handle_payment',
PaypalPaymentExecutionView, 'handle_payment', side_effect=PaymentError side_effect=PaymentError) as fake_handle_payment:
) as fake_handle_payment:
self._assert_execution_redirect() self._assert_execution_redirect()
self.assertTrue(fake_handle_payment.called) self.assertTrue(fake_handle_payment.called)
...@@ -231,8 +229,7 @@ class PaypalPaymentExecutionViewTests(PaypalMixin, PaymentEventsMixin, TestCase) ...@@ -231,8 +229,7 @@ class PaypalPaymentExecutionViewTests(PaypalMixin, PaymentEventsMixin, TestCase)
Verify that a user who has approved payment is redirected to the configured receipt page when the payment Verify that a user who has approved payment is redirected to the configured receipt page when the payment
is executed but an order cannot be placed. is executed but an order cannot be placed.
""" """
with mock.patch.object( with mock.patch.object(PaypalPaymentExecutionView, 'handle_order_placement',
PaypalPaymentExecutionView, 'handle_order_placement', side_effect=UnableToPlaceOrder side_effect=UnableToPlaceOrder) as fake_handle_order_placement:
) as fake_handle_order_placement:
self._assert_execution_redirect() self._assert_execution_redirect()
self.assertTrue(fake_handle_order_placement.called) self.assertTrue(fake_handle_order_placement.called)
import logging
from django.conf import settings from django.conf import settings
from django.db import models from django.db import models
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django_extensions.db.models import TimeStampedModel from django_extensions.db.models import TimeStampedModel
from oscar.apps.payment.exceptions import PaymentError
from oscar.core.loading import get_class
from oscar.core.utils import get_default_currency from oscar.core.utils import get_default_currency
from simple_history.models import HistoricalRecords from simple_history.models import HistoricalRecords
from ecommerce.extensions.fulfillment.api import revoke_fulfillment_for_refund
from ecommerce.extensions.payment.helpers import get_processor_class_by_name
from ecommerce.extensions.refund.exceptions import InvalidStatus from ecommerce.extensions.refund.exceptions import InvalidStatus
from ecommerce.extensions.refund.status import REFUND from ecommerce.extensions.refund.status import REFUND, REFUND_LINE
logger = logging.getLogger(__name__)
post_refund = get_class('refund.signals', 'post_refund')
class StatusMixin(object): class StatusMixin(object):
...@@ -83,6 +93,63 @@ class Refund(StatusMixin, TimeStampedModel): ...@@ -83,6 +93,63 @@ class Refund(StatusMixin, TimeStampedModel):
""" """
return self.status == settings.OSCAR_INITIAL_REFUND_STATUS return self.status == settings.OSCAR_INITIAL_REFUND_STATUS
def _issue_credit(self):
""" Issue a credit to the purchaser via the payment processor used for the original order. """
# TODO Update this if we ever support multiple payment sources for a single order.
source = self.order.sources.first()
processor = get_processor_class_by_name(source.source_type.name)()
processor.issue_credit(source, self.total_credit_excl_tax, self.currency)
def _revoke_lines(self):
""" Revoke fulfillment for the lines in this Refund. """
if revoke_fulfillment_for_refund(self):
self.set_status(REFUND.COMPLETE)
logger.info('Successfully revoked fulfillment for Refund [%d]', self.id)
else:
logger.error('Unable to revoke fulfillment of all lines of Refund [%d].', self.id)
self.set_status(REFUND.REVOCATION_ERROR)
def approve(self):
if not self.can_approve:
logger.debug('Refund [%d] cannot be approved.', self.id)
return False
elif self.status in (REFUND.OPEN, REFUND.PAYMENT_REFUND_ERROR):
try:
self._issue_credit()
logger.info('Successfully issued credit for Refund [%d]', self.id)
self.set_status(REFUND.PAYMENT_REFUNDED)
except PaymentError:
logger.exception('Failed to issue credit for refund [%d].', self.id)
self.set_status(REFUND.PAYMENT_REFUND_ERROR)
return False
if self.status in (REFUND.PAYMENT_REFUNDED, REFUND.REVOCATION_ERROR):
self._revoke_lines()
if self.status == REFUND.COMPLETE:
post_refund.send(sender=self.__class__, refund=self)
return True
return False
def deny(self):
if not self.can_deny:
logger.debug('Refund [%d] cannot be denied.', self.id)
return False
self.set_status(REFUND.DENIED)
result = True
for line in self.lines.all():
try:
line.deny()
except Exception: # pylint: disable=broad-except
logger.exception('Failed to deny RefundLine [%d].', line.id)
result = False
return result
class RefundLine(StatusMixin, TimeStampedModel): class RefundLine(StatusMixin, TimeStampedModel):
"""A refund line, used to represent the state of a single item as part of a larger Refund.""" """A refund line, used to represent the state of a single item as part of a larger Refund."""
...@@ -94,3 +161,7 @@ class RefundLine(StatusMixin, TimeStampedModel): ...@@ -94,3 +161,7 @@ class RefundLine(StatusMixin, TimeStampedModel):
history = HistoricalRecords() history = HistoricalRecords()
pipeline_setting = 'OSCAR_REFUND_LINE_STATUS_PIPELINE' pipeline_setting = 'OSCAR_REFUND_LINE_STATUS_PIPELINE'
def deny(self):
self.set_status(REFUND_LINE.DENIED)
return True
from django.dispatch import Signal
# This signal should be emitted after a refund is completed--payment credited AND fulfillment revoked.
post_refund = Signal(providing_args=["refund"])
# TODO Track refund: https://support.google.com/analytics/answer/1037443?hl=en
class REFUND(object): class REFUND(object):
OPEN = 'Open' OPEN = 'Open'
DENIED = 'Denied' DENIED = 'Denied'
ERROR = 'Error' PAYMENT_REFUND_ERROR = 'Payment Refund Error'
PAYMENT_REFUNDED = 'Payment Refunded'
REVOCATION_ERROR = 'Revocation Error'
COMPLETE = 'Complete' COMPLETE = 'Complete'
class REFUND_LINE(object): class REFUND_LINE(object):
OPEN = 'Open' OPEN = 'Open'
PAYMENT_REFUND_ERROR = 'Payment Refund Error'
PAYMENT_REFUNDED = 'Payment Refunded'
REVOCATION_ERROR = 'Revocation Error' REVOCATION_ERROR = 'Revocation Error'
DENIED = 'Denied' DENIED = 'Denied'
COMPLETE = 'Complete' COMPLETE = 'Complete'
...@@ -25,6 +25,17 @@ class RefundFactory(factory.DjangoModelFactory): ...@@ -25,6 +25,17 @@ class RefundFactory(factory.DjangoModelFactory):
def order(self): def order(self):
return factories.create_order(user=self.user) return factories.create_order(user=self.user)
@factory.post_generation
def create_lines(self, create, extracted, **kwargs): # pylint: disable=unused-argument
if not create:
return
for __ in range(2):
RefundLineFactory.create(refund=self)
self.total_credit_excl_tax = sum([line.line_credit_excl_tax for line in self.lines.all()])
self.save()
class Meta(object): class Meta(object):
model = get_model('refund', 'Refund') model = get_model('refund', 'Refund')
......
from oscar.core.loading import get_model
from ecommerce.extensions.refund.tests.factories import RefundFactory
Source = get_model('payment', 'Source')
SourceType = get_model('payment', 'SourceType')
class RefundTestMixin(object):
def create_refund(self, processor_name='cybersource'):
refund = RefundFactory()
order = refund.order
source_type, __ = SourceType.objects.get_or_create(name=processor_name)
Source.objects.create(source_type=source_type, order=order, currency=refund.currency,
amount_allocated=order.total_incl_tax, amount_debited=order.total_incl_tax)
return refund
import ddt import ddt
from django.test import TestCase, override_settings from django.conf import settings
from oscar.core.loading import get_model from django.test import TestCase
import mock
from mock_django import mock_signal_receiver
from oscar.apps.payment.exceptions import PaymentError
from oscar.core.loading import get_model, get_class
from ecommerce.extensions.refund import models
from ecommerce.extensions.refund.exceptions import InvalidStatus from ecommerce.extensions.refund.exceptions import InvalidStatus
from ecommerce.extensions.refund.status import REFUND, REFUND_LINE from ecommerce.extensions.refund.status import REFUND, REFUND_LINE
from ecommerce.extensions.refund.tests.factories import RefundFactory, RefundLineFactory from ecommerce.extensions.refund.tests.factories import RefundFactory, RefundLineFactory
OSCAR_REFUND_STATUS_PIPELINE = { post_refund = get_class('refund.signals', 'post_refund')
REFUND.OPEN: (REFUND.DENIED, REFUND.ERROR, REFUND.COMPLETE),
REFUND.ERROR: (REFUND.COMPLETE, REFUND.ERROR),
REFUND.DENIED: (),
REFUND.COMPLETE: ()
}
OSCAR_REFUND_LINE_STATUS_PIPELINE = {
REFUND_LINE.OPEN: (REFUND_LINE.DENIED, REFUND_LINE.PAYMENT_REFUND_ERROR, REFUND_LINE.PAYMENT_REFUNDED),
REFUND_LINE.PAYMENT_REFUND_ERROR: (REFUND_LINE.PAYMENT_REFUNDED,),
REFUND_LINE.PAYMENT_REFUNDED: (REFUND_LINE.COMPLETE, REFUND_LINE.REVOCATION_ERROR),
REFUND_LINE.REVOCATION_ERROR: (REFUND_LINE.COMPLETE,),
REFUND_LINE.DENIED: (),
REFUND_LINE.COMPLETE: ()
}
Refund = get_model('refund', 'Refund') Refund = get_model('refund', 'Refund')
...@@ -64,30 +54,30 @@ class StatusTestsMixin(object): ...@@ -64,30 +54,30 @@ class StatusTestsMixin(object):
@ddt.ddt @ddt.ddt
@override_settings(OSCAR_REFUND_STATUS_PIPELINE=OSCAR_REFUND_STATUS_PIPELINE, OSCAR_INITIAL_REFUND_STATUS=REFUND.OPEN)
class RefundTests(StatusTestsMixin, TestCase): class RefundTests(StatusTestsMixin, TestCase):
pipeline = OSCAR_REFUND_STATUS_PIPELINE pipeline = settings.OSCAR_REFUND_STATUS_PIPELINE
def _get_instance(self, **kwargs): def _get_instance(self, **kwargs):
return RefundFactory(**kwargs) return RefundFactory(**kwargs)
def test_num_items(self): def test_num_items(self):
""" The method should return the total number of items being refunded. """ """ The method should return the total number of items being refunded. """
refund_line = RefundLineFactory(quantity=1) refund = RefundFactory()
refund = refund_line.refund self.assertEqual(refund.num_items, 2)
self.assertEqual(refund.num_items, 1)
RefundLineFactory(quantity=3, refund=refund) RefundLineFactory(quantity=3, refund=refund)
self.assertEqual(refund.num_items, 4) self.assertEqual(refund.num_items, 5)
def test_all_statuses(self): def test_all_statuses(self):
""" Refund.all_statuses should return all possible statuses for a refund. """ """ Refund.all_statuses should return all possible statuses for a refund. """
self.assertEqual(Refund.all_statuses(), OSCAR_REFUND_STATUS_PIPELINE.keys()) self.assertEqual(Refund.all_statuses(), self.pipeline.keys())
@ddt.unpack @ddt.unpack
@ddt.data( @ddt.data(
(REFUND.OPEN, True), (REFUND.OPEN, True),
(REFUND.ERROR, True), (REFUND.PAYMENT_REFUND_ERROR, True),
(REFUND.PAYMENT_REFUNDED, True),
(REFUND.REVOCATION_ERROR, True),
(REFUND.DENIED, False), (REFUND.DENIED, False),
(REFUND.COMPLETE, False), (REFUND.COMPLETE, False),
) )
...@@ -99,7 +89,7 @@ class RefundTests(StatusTestsMixin, TestCase): ...@@ -99,7 +89,7 @@ class RefundTests(StatusTestsMixin, TestCase):
@ddt.unpack @ddt.unpack
@ddt.data( @ddt.data(
(REFUND.OPEN, True), (REFUND.OPEN, True),
(REFUND.ERROR, False), (REFUND.REVOCATION_ERROR, False),
(REFUND.DENIED, False), (REFUND.DENIED, False),
(REFUND.COMPLETE, False), (REFUND.COMPLETE, False),
) )
...@@ -108,10 +98,107 @@ class RefundTests(StatusTestsMixin, TestCase): ...@@ -108,10 +98,107 @@ class RefundTests(StatusTestsMixin, TestCase):
refund = self._get_instance(status=status) refund = self._get_instance(status=status)
self.assertEqual(refund.can_deny, expected) self.assertEqual(refund.can_deny, expected)
def assert_line_status(self, refund, status):
for line in refund.lines.all():
self.assertEqual(line.status, status)
def test_approve(self):
"""
If payment refund and fulfillment revocation succeed, the method should update the status of the Refund and
RefundLine objects to Complete, and return True.
"""
refund = self._get_instance()
def _revoke_lines(r):
for line in r.lines.all():
line.set_status(REFUND_LINE.COMPLETE)
r.set_status(REFUND.COMPLETE)
with mock.patch.object(Refund, '_issue_credit', return_value=None):
with mock.patch.object(Refund, '_revoke_lines', side_effect=_revoke_lines, autospec=True):
with mock_signal_receiver(post_refund) as receiver:
self.assertEqual(receiver.call_count, 0)
self.assertTrue(refund.approve())
self.assertEqual(receiver.call_count, 1)
def test_approve_payment_error(self):
"""
If payment refund fails, the Refund status should be set to Payment Refund Error, and the RefundLine
objects' statuses to Open.
"""
refund = self._get_instance()
with mock.patch.object(Refund, '_issue_credit', side_effect=PaymentError):
self.assertFalse(refund.approve())
self.assertEqual(refund.status, REFUND.PAYMENT_REFUND_ERROR)
self.assert_line_status(refund, REFUND_LINE.OPEN)
def test_approve_revocation_error(self):
"""
If fulfillment revocation fails, Refund status should be set to Revocation Error and the RefundLine objects'
statuses set to Revocation Error.
"""
refund = self._get_instance()
def revoke_fulfillment_for_refund(r):
for line in r.lines.all():
line.set_status(REFUND_LINE.REVOCATION_ERROR)
return False
with mock.patch.object(Refund, '_issue_credit', return_value=None):
with mock.patch.object(models, 'revoke_fulfillment_for_refund') as mock_revoke:
mock_revoke.side_effect = revoke_fulfillment_for_refund
self.assertFalse(refund.approve())
self.assertEqual(refund.status, REFUND.REVOCATION_ERROR)
self.assert_line_status(refund, REFUND_LINE.REVOCATION_ERROR)
@ddt.data(REFUND.COMPLETE, REFUND.DENIED)
def test_approve_wrong_state(self, status):
""" The method should return False if the Refund cannot be approved. """
refund = self._get_instance(status=status)
self.assertEqual(refund.status, status)
self.assert_line_status(refund, REFUND_LINE.OPEN)
self.assertFalse(refund.approve())
self.assertEqual(refund.status, status)
self.assert_line_status(refund, REFUND_LINE.OPEN)
def test_deny(self):
"""
The method should update the state of the Refund and related RefundLine objects, if the Refund can be
denied, and return True.
"""
refund = self._get_instance()
self.assertEqual(refund.status, REFUND.OPEN)
self.assert_line_status(refund, REFUND_LINE.OPEN)
self.assertTrue(refund.deny())
self.assertEqual(refund.status, REFUND.DENIED)
self.assert_line_status(refund, REFUND_LINE.DENIED)
@ddt.data(REFUND.REVOCATION_ERROR, REFUND.PAYMENT_REFUNDED, REFUND.PAYMENT_REFUND_ERROR, REFUND.COMPLETE)
def test_deny_wrong_state(self, status):
""" The method should return False if the Refund cannot be denied. """
refund = self._get_instance(status=status)
self.assertEqual(refund.status, status)
self.assert_line_status(refund, REFUND_LINE.OPEN)
self.assertFalse(refund.deny())
self.assertEqual(refund.status, status)
self.assert_line_status(refund, REFUND_LINE.OPEN)
@override_settings(OSCAR_REFUND_LINE_STATUS_PIPELINE=OSCAR_REFUND_LINE_STATUS_PIPELINE)
class RefundLineTests(StatusTestsMixin, TestCase): class RefundLineTests(StatusTestsMixin, TestCase):
pipeline = OSCAR_REFUND_LINE_STATUS_PIPELINE pipeline = settings.OSCAR_REFUND_LINE_STATUS_PIPELINE
def _get_instance(self, **kwargs): def _get_instance(self, **kwargs):
return RefundLineFactory(**kwargs) return RefundLineFactory(**kwargs)
def test_deny(self):
""" The method sets the status to Denied. """
line = self._get_instance()
self.assertEqual(line.status, REFUND_LINE.OPEN)
self.assertTrue(line.deny())
self.assertEqual(line.status, REFUND_LINE.DENIED)
...@@ -133,17 +133,17 @@ OSCAR_INITIAL_REFUND_STATUS = REFUND.OPEN ...@@ -133,17 +133,17 @@ OSCAR_INITIAL_REFUND_STATUS = REFUND.OPEN
OSCAR_INITIAL_REFUND_LINE_STATUS = REFUND_LINE.OPEN OSCAR_INITIAL_REFUND_LINE_STATUS = REFUND_LINE.OPEN
OSCAR_REFUND_STATUS_PIPELINE = { OSCAR_REFUND_STATUS_PIPELINE = {
REFUND.OPEN: (REFUND.DENIED, REFUND.ERROR, REFUND.COMPLETE), REFUND.OPEN: (REFUND.DENIED, REFUND.PAYMENT_REFUND_ERROR, REFUND.PAYMENT_REFUNDED),
REFUND.ERROR: (REFUND.COMPLETE, REFUND.ERROR), REFUND.PAYMENT_REFUND_ERROR: (REFUND.PAYMENT_REFUNDED, REFUND.PAYMENT_REFUND_ERROR),
REFUND.PAYMENT_REFUNDED: (REFUND.REVOCATION_ERROR, REFUND.COMPLETE),
REFUND.REVOCATION_ERROR: (REFUND.REVOCATION_ERROR, REFUND.COMPLETE),
REFUND.DENIED: (), REFUND.DENIED: (),
REFUND.COMPLETE: () REFUND.COMPLETE: ()
} }
OSCAR_REFUND_LINE_STATUS_PIPELINE = { OSCAR_REFUND_LINE_STATUS_PIPELINE = {
REFUND_LINE.OPEN: (REFUND_LINE.DENIED, REFUND_LINE.PAYMENT_REFUND_ERROR, REFUND_LINE.PAYMENT_REFUNDED), REFUND_LINE.OPEN: (REFUND_LINE.DENIED, REFUND_LINE.REVOCATION_ERROR, REFUND_LINE.COMPLETE),
REFUND_LINE.PAYMENT_REFUND_ERROR: (REFUND_LINE.PAYMENT_REFUNDED, REFUND_LINE.PAYMENT_REFUND_ERROR), REFUND_LINE.REVOCATION_ERROR: (REFUND_LINE.REVOCATION_ERROR, REFUND_LINE.COMPLETE),
REFUND_LINE.PAYMENT_REFUNDED: (REFUND_LINE.COMPLETE, REFUND_LINE.REVOCATION_ERROR),
REFUND_LINE.REVOCATION_ERROR: (REFUND_LINE.COMPLETE, REFUND_LINE.REVOCATION_ERROR),
REFUND_LINE.DENIED: (), REFUND_LINE.DENIED: (),
REFUND_LINE.COMPLETE: () REFUND_LINE.COMPLETE: ()
} }
...@@ -225,3 +225,6 @@ OSCAR_DASHBOARD_NAVIGATION = [ ...@@ -225,3 +225,6 @@ OSCAR_DASHBOARD_NAVIGATION = [
}, },
] ]
# END DASHBOARD NAVIGATION MENU # END DASHBOARD NAVIGATION MENU
# Default timeout for Enrollment API calls
ENROLLMENT_FULFILLMENT_TIMEOUT = 5
...@@ -94,7 +94,7 @@ JWT_AUTH['JWT_SECRET_KEY'] = 'insecure-secret-key' ...@@ -94,7 +94,7 @@ JWT_AUTH['JWT_SECRET_KEY'] = 'insecure-secret-key'
# ORDER PROCESSING # ORDER PROCESSING
ENROLLMENT_API_URL = get_lms_url('/api/enrollment/v1/enrollment') ENROLLMENT_API_URL = get_lms_url('/api/enrollment/v1/enrollment')
ENROLLMENT_FULFILLMENT_TIMEOUT = 15 # devstack is slow! ENROLLMENT_FULFILLMENT_TIMEOUT = 15 # devstack is slow!
EDX_API_KEY = 'replace-me' EDX_API_KEY = 'replace-me'
# END ORDER PROCESSING # END ORDER PROCESSING
...@@ -103,10 +103,13 @@ EDX_API_KEY = 'replace-me' ...@@ -103,10 +103,13 @@ EDX_API_KEY = 'replace-me'
# PAYMENT PROCESSING # PAYMENT PROCESSING
PAYMENT_PROCESSOR_CONFIG = { PAYMENT_PROCESSOR_CONFIG = {
'cybersource': { 'cybersource': {
'soap_api_url': 'https://ics2wstest.ic3.com/commerce/1.x/transactionProcessor/CyberSourceTransaction_1.115.wsdl',
'merchant_id': 'fake-merchant-id',
'transaction_key': 'fake-transaction-key',
'profile_id': 'fake-profile-id', 'profile_id': 'fake-profile-id',
'access_key': 'fake-access-key', 'access_key': 'fake-access-key',
'secret_key': 'fake-secret-key', 'secret_key': 'fake-secret-key',
'payment_page_url': 'https://replace-me/', 'payment_page_url': 'https://testsecureacceptance.cybersource.com/pay',
'receipt_page_url': get_lms_url('/commerce/checkout/receipt/'), 'receipt_page_url': get_lms_url('/commerce/checkout/receipt/'),
'cancel_page_url': get_lms_url('/commerce/checkout/cancel/'), 'cancel_page_url': get_lms_url('/commerce/checkout/cancel/'),
}, },
......
...@@ -73,6 +73,16 @@ def get_logger_config(log_dir='/var/tmp', ...@@ -73,6 +73,16 @@ def get_logger_config(log_dir='/var/tmp',
'propagate': True, 'propagate': True,
'level': 'WARNING' 'level': 'WARNING'
}, },
'suds': {
'handlers': handlers,
'propagate': True,
'level': 'WARNING'
},
'factory': {
'handlers': handlers,
'propagate': True,
'level': 'WARNING'
},
'django.request': { 'django.request': {
'handlers': handlers, 'handlers': handlers,
'propagate': True, 'propagate': True,
......
...@@ -3,6 +3,7 @@ from __future__ import absolute_import ...@@ -3,6 +3,7 @@ from __future__ import absolute_import
import os import os
from ecommerce.settings.base import * from ecommerce.settings.base import *
from ecommerce.settings.logger import get_logger_config
# TEST SETTINGS # TEST SETTINGS
...@@ -12,6 +13,8 @@ INSTALLED_APPS += ( ...@@ -12,6 +13,8 @@ INSTALLED_APPS += (
TEST_RUNNER = 'django_nose.NoseTestSuiteRunner' TEST_RUNNER = 'django_nose.NoseTestSuiteRunner'
LOGGING = get_logger_config(debug=DEBUG, dev_env=True, local_loglevel='DEBUG')
class DisableMigrations(object): class DisableMigrations(object):
"""Override method calls on the MIGRATION_MODULES dictionary. """Override method calls on the MIGRATION_MODULES dictionary.
...@@ -85,6 +88,9 @@ EDX_API_KEY = 'replace-me' ...@@ -85,6 +88,9 @@ EDX_API_KEY = 'replace-me'
# PAYMENT PROCESSING # PAYMENT PROCESSING
PAYMENT_PROCESSOR_CONFIG = { PAYMENT_PROCESSOR_CONFIG = {
'cybersource': { 'cybersource': {
'soap_api_url': 'https://ics2wstest.ic3.com/commerce/1.x/transactionProcessor/CyberSourceTransaction_1.115.wsdl',
'merchant_id': 'fake-merchant-id',
'transaction_key': 'fake-transaction-key',
'profile_id': 'fake-profile-id', 'profile_id': 'fake-profile-id',
'access_key': 'fake-access-key', 'access_key': 'fake-access-key',
'secret_key': 'fake-secret-key', 'secret_key': 'fake-secret-key',
......
...@@ -12,3 +12,4 @@ jsonfield==1.0.3 ...@@ -12,3 +12,4 @@ jsonfield==1.0.3
paypalrestsdk==1.9.0 paypalrestsdk==1.9.0
pycountry==1.10 pycountry==1.10
requests==2.6.0 requests==2.6.0
suds==0.4
...@@ -8,9 +8,11 @@ django-nose==1.3 ...@@ -8,9 +8,11 @@ django-nose==1.3
django_dynamic_fixture==1.8.1 django_dynamic_fixture==1.8.1
httpretty==0.8.8 httpretty==0.8.8
mock==1.0.1 mock==1.0.1
mock-django==0.6.8
nose-ignore-docstring==0.2 nose-ignore-docstring==0.2
pep8==1.6.2 pep8==1.6.2
pylint==1.4.1 pylint==1.4.1
selenium==2.45.0 selenium==2.45.0
testfixtures==4.1.2
git+https://github.com/edx/ecommerce-api-client.git@1.0.0#egg=ecommerce-api-client==1.0.0 git+https://github.com/edx/ecommerce-api-client.git@1.0.0#egg=ecommerce-api-client==1.0.0
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment