Commit 7ab3e44b by Renzo Lucioni

Automatically approve refunds corresponding to a total credit of $0

Also prevent completed and denied refunds from appearing in the default refund list view, and allow filtering of refunds by multiple statuses.
parent 5a6509c5
...@@ -2,10 +2,20 @@ from django import forms ...@@ -2,10 +2,20 @@ from django import forms
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from oscar.core.loading import get_model from oscar.core.loading import get_model
from ecommerce.extensions.refund.status import REFUND
Refund = get_model('refund', 'Refund') Refund = get_model('refund', 'Refund')
class RefundSearchForm(forms.Form): class RefundSearchForm(forms.Form):
id = forms.IntegerField(required=False, label=_('Refund ID')) id = forms.IntegerField(required=False, label=_('Refund ID'))
status_choices = (('', '---------'),) + tuple([(status, status) for status in Refund.all_statuses()]) status_choices = tuple([(status, status) for status in Refund.all_statuses()])
status = forms.ChoiceField(choices=status_choices, label=_("Status"), required=False) status = forms.MultipleChoiceField(choices=status_choices, label=_("Status"), required=False)
def clean(self):
cleaned_data = super(RefundSearchForm, self).clean()
if not cleaned_data.get('status'):
# If no statuses are specified, default to displaying all those refunds requiring action.
cleaned_data['status'] = list(set(Refund.all_statuses()) - set((REFUND.COMPLETE, REFUND.DENIED)))
...@@ -52,21 +52,30 @@ class RefundListViewTests(RefundViewTestMixin, TestCase): ...@@ -52,21 +52,30 @@ class RefundListViewTests(RefundViewTestMixin, TestCase):
refund = RefundFactory() refund = RefundFactory()
open_refund = RefundFactory(status=REFUND.OPEN) open_refund = RefundFactory(status=REFUND.OPEN)
complete_refund = RefundFactory(status=REFUND.COMPLETE) complete_refund = RefundFactory(status=REFUND.COMPLETE)
denied_refund = RefundFactory(status=REFUND.DENIED)
self.client.login(username=self.user.username, password=self.password) self.client.login(username=self.user.username, password=self.password)
# Sanity check for an unfiltered query # Sanity check for an unfiltered query. Completed and denied refunds should be excluded.
response = self.client.get(self.path) response = self.client.get(self.path)
self.assert_successful_response(response, [refund, open_refund, complete_refund]) self.assert_successful_response(response, [refund, open_refund])
# ID filtering # ID filtering
response = self.client.get('{path}?id={id}'.format(path=self.path, id=open_refund.id)) response = self.client.get('{path}?id={id}'.format(path=self.path, id=open_refund.id))
self.assert_successful_response(response, [open_refund]) self.assert_successful_response(response, [open_refund])
# Status filtering # Single-choice status filtering
response = self.client.get('{path}?status={status}'.format(path=self.path, status=REFUND.COMPLETE)) response = self.client.get('{path}?status={status}'.format(path=self.path, status=REFUND.COMPLETE))
self.assert_successful_response(response, [complete_refund]) self.assert_successful_response(response, [complete_refund])
# Multiple-choice status filtering
response = self.client.get('{path}?status={complete_status}&status={denied_status}'.format(
path=self.path,
complete_status=REFUND.COMPLETE,
denied_status=REFUND.DENIED
))
self.assert_successful_response(response, [complete_refund, denied_refund])
def test_sorting(self): def test_sorting(self):
""" The view should allow sorting by ID. """ """ The view should allow sorting by ID. """
refunds = [RefundFactory(), RefundFactory(), RefundFactory()] refunds = [RefundFactory(), RefundFactory(), RefundFactory()]
......
...@@ -25,7 +25,9 @@ class RefundListView(ListView): ...@@ -25,7 +25,9 @@ class RefundListView(ListView):
if self.form.is_valid(): if self.form.is_valid():
for field, value in self.form.cleaned_data.iteritems(): for field, value in self.form.cleaned_data.iteritems():
if value: if field == 'status' and value:
queryset = queryset.filter(status__in=value)
elif value:
queryset = queryset.filter(**{field: value}) queryset = queryset.filter(**{field: value})
return queryset return queryset
......
...@@ -75,7 +75,8 @@ class Refund(StatusMixin, TimeStampedModel): ...@@ -75,7 +75,8 @@ class Refund(StatusMixin, TimeStampedModel):
def create_with_lines(cls, order, lines): def create_with_lines(cls, order, lines):
"""Given an order and order lines, creates a Refund with corresponding RefundLines. """Given an order and order lines, creates a Refund with corresponding RefundLines.
Only creates RefundLines for unrefunded order lines. Only creates RefundLines for unrefunded order lines. Refunds corresponding to a total
credit of $0 are approved upon creation.
Arguments: Arguments:
order (order.Order): The order to which the newly-created refund corresponds. order (order.Order): The order to which the newly-created refund corresponds.
...@@ -106,6 +107,9 @@ class Refund(StatusMixin, TimeStampedModel): ...@@ -106,6 +107,9 @@ class Refund(StatusMixin, TimeStampedModel):
status=status status=status
) )
if total_credit_excl_tax == 0:
refund.approve()
return refund return refund
@property @property
...@@ -128,11 +132,15 @@ class Refund(StatusMixin, TimeStampedModel): ...@@ -128,11 +132,15 @@ class Refund(StatusMixin, TimeStampedModel):
def _issue_credit(self): def _issue_credit(self):
"""Issue a credit to the purchaser via the payment processor used for the original order.""" """Issue a credit to the purchaser via the payment processor used for the original order."""
try:
# TODO Update this if we ever support multiple payment sources for a single order. # TODO Update this if we ever support multiple payment sources for a single order.
source = self.order.sources.first() source = self.order.sources.first()
processor = get_processor_class_by_name(source.source_type.name)() processor = get_processor_class_by_name(source.source_type.name)()
processor.issue_credit(source, self.total_credit_excl_tax, self.currency) processor.issue_credit(source, self.total_credit_excl_tax, self.currency)
except AttributeError:
# Order has no sources, resulting in an exception when trying to access `source_type`.
# This occurs when attempting to refund free orders.
logger.info("No payments to credit for Refund [%d]", self.id)
def _revoke_lines(self): def _revoke_lines(self):
"""Revoke fulfillment for the lines in this Refund.""" """Revoke fulfillment for the lines in this Refund."""
......
...@@ -26,13 +26,17 @@ class RefundTestMixin(object): ...@@ -26,13 +26,17 @@ class RefundTestMixin(object):
self.honor_product = self.course.add_mode('honor', 0) self.honor_product = self.course.add_mode('honor', 0)
self.verified_product = self.course.add_mode('verified', Decimal(10.00), id_verification_required=True) self.verified_product = self.course.add_mode('verified', Decimal(10.00), id_verification_required=True)
def create_order(self, user=None, multiple_lines=False): def create_order(self, user=None, multiple_lines=False, free=False):
user = user or self.user user = user or self.user
basket = BasketFactory(owner=user) basket = BasketFactory(owner=user)
basket.add_product(self.verified_product)
if multiple_lines: if multiple_lines:
basket.add_product(self.verified_product)
basket.add_product(self.honor_product) basket.add_product(self.honor_product)
elif free:
basket.add_product(self.honor_product)
else:
basket.add_product(self.verified_product)
order = create_order(basket=basket, user=user) order = create_order(basket=basket, user=user)
order.status = ORDER.COMPLETE order.status = ORDER.COMPLETE
......
import ddt import ddt
from django.conf import settings from django.conf import settings
from django.test import TestCase from django.test import TestCase
import httpretty
import mock import mock
from oscar.apps.payment.exceptions import PaymentError from oscar.apps.payment.exceptions import PaymentError
from oscar.core.loading import get_model, get_class from oscar.core.loading import get_model, get_class
...@@ -96,6 +97,32 @@ class RefundTests(RefundTestMixin, StatusTestsMixin, TestCase): ...@@ -96,6 +97,32 @@ class RefundTests(RefundTestMixin, StatusTestsMixin, TestCase):
self.assertEqual(refund, None) self.assertEqual(refund, None)
@httpretty.activate
def test_zero_dollar_refund(self):
"""
Given an order and order lines which total $0 and are not refunded, Refund.create_with_lines
should create and approve a Refund with corresponding RefundLines.
"""
httpretty.register_uri(
httpretty.POST,
settings.ENROLLMENT_API_URL,
status=200,
body='{}',
content_type='application/json'
)
order = self.create_order(user=UserFactory(), free=True)
# Verify that the order totals $0.
self.assertEqual(order.total_excl_tax, 0)
refund = Refund.create_with_lines(order, list(order.lines.all()))
# Verify that the refund has been successfully approved.
self.assertEqual(refund.status, REFUND.COMPLETE)
for line in refund.lines.all():
self.assertEqual(line.status, REFUND_LINE.COMPLETE)
@ddt.unpack @ddt.unpack
@ddt.data( @ddt.data(
(REFUND.OPEN, True), (REFUND.OPEN, True),
......
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