Commit 4965cd3c by Renzo Lucioni

Add Refund class method for creating targeted Refunds

Given an order and order lines, creates a Refund with corresponding RefundLines. Only creates RefundLines for unrefunded order lines.
parent e0c2373f
from django.conf import settings
from oscar.core.loading import get_model from oscar.core.loading import get_model
from ecommerce.extensions.fulfillment.status import ORDER from ecommerce.extensions.fulfillment.status import ORDER
from ecommerce.extensions.refund.status import REFUND, REFUND_LINE
Refund = get_model('refund', 'Refund') Refund = get_model('refund', 'Refund')
...@@ -59,19 +56,8 @@ def create_refunds(orders, course_id): ...@@ -59,19 +56,8 @@ def create_refunds(orders, course_id):
product__attribute_values__attribute__code='course_key', product__attribute_values__attribute__code='course_key',
product__attribute_values__value_text=course_id) product__attribute_values__value_text=course_id)
# Only create a refund if there are line items to refund. refund = Refund.create_with_lines(order, lines)
if lines: if refund is not None:
total_credit_excl_tax = sum([line.line_price_excl_tax for line in lines])
status = getattr(settings, 'OSCAR_INITIAL_REFUND_STATUS', REFUND.OPEN)
refund = Refund.objects.create(order=order, user=order.user, status=status,
total_credit_excl_tax=total_credit_excl_tax)
status = getattr(settings, 'OSCAR_INITIAL_REFUND_LINE_STATUS', REFUND_LINE.OPEN)
for line in lines:
RefundLine.objects.create(refund=refund, order_line=line, line_credit_excl_tax=line.line_price_excl_tax,
quantity=line.quantity, status=status)
refunds.append(refund) refunds.append(refund)
return refunds return refunds
...@@ -14,6 +14,7 @@ from ecommerce.extensions.payment.helpers import get_processor_class_by_name ...@@ -14,6 +14,7 @@ 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, REFUND_LINE from ecommerce.extensions.refund.status import REFUND, REFUND_LINE
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
post_refund = get_class('refund.signals', 'post_refund') post_refund = get_class('refund.signals', 'post_refund')
...@@ -29,13 +30,12 @@ class StatusMixin(object): ...@@ -29,13 +30,12 @@ class StatusMixin(object):
return getattr(settings, self.pipeline_setting) return getattr(settings, self.pipeline_setting)
def available_statuses(self): def available_statuses(self):
""" Returns all possible statuses that this object can move to. """ """Returns all possible statuses that this object can move to."""
return self.pipeline.get(self.status, ()) return self.pipeline.get(self.status, ())
# pylint: disable=access-member-before-definition,attribute-defined-outside-init # pylint: disable=access-member-before-definition,attribute-defined-outside-init
def set_status(self, new_status): def set_status(self, new_status):
""" """Set a new status for this object.
Set a new status for this object.
If the requested status is not valid, then ``InvalidStatus`` is raised. If the requested status is not valid, then ``InvalidStatus`` is raised.
""" """
...@@ -68,12 +68,49 @@ class Refund(StatusMixin, TimeStampedModel): ...@@ -68,12 +68,49 @@ class Refund(StatusMixin, TimeStampedModel):
@classmethod @classmethod
def all_statuses(cls): def all_statuses(cls):
""" Returns all possible statuses for a refund. """ """Returns all possible statuses for a refund."""
return list(getattr(settings, cls.pipeline_setting).keys()) return list(getattr(settings, cls.pipeline_setting).keys())
@classmethod
def create_with_lines(cls, order, lines):
"""Given an order and order lines, creates a Refund with corresponding RefundLines.
Only creates RefundLines for unrefunded order lines.
Arguments:
order (order.Order): The order to which the newly-created refund corresponds.
lines (list of order.Line): Order lines to be refunded.
Returns:
None: If no unrefunded order lines have been provided.
Refund: With RefundLines corresponding to each given unrefunded order line.
"""
unrefunded_lines = [line for line in lines if not line.refund_lines.exists()]
if unrefunded_lines:
status = getattr(settings, 'OSCAR_INITIAL_REFUND_STATUS', REFUND.OPEN)
total_credit_excl_tax = sum([line.line_price_excl_tax for line in unrefunded_lines])
refund = cls.objects.create(
order=order,
user=order.user,
status=status,
total_credit_excl_tax=total_credit_excl_tax
)
status = getattr(settings, 'OSCAR_INITIAL_REFUND_LINE_STATUS', REFUND_LINE.OPEN)
for line in unrefunded_lines:
RefundLine.objects.create(
refund=refund,
order_line=line,
line_credit_excl_tax=line.line_price_excl_tax,
quantity=line.quantity,
status=status
)
return refund
@property @property
def num_items(self): def num_items(self):
""" Returns the number of items in this refund. """ """Returns the number of items in this refund."""
num_items = 0 num_items = 0
for line in self.lines.all(): for line in self.lines.all():
num_items += line.quantity num_items += line.quantity
...@@ -81,20 +118,16 @@ class Refund(StatusMixin, TimeStampedModel): ...@@ -81,20 +118,16 @@ class Refund(StatusMixin, TimeStampedModel):
@property @property
def can_approve(self): def can_approve(self):
""" """Returns a boolean indicating if this Refund can be approved."""
Returns a boolean indicating if this Refund can be approved.
"""
return self.status not in (REFUND.COMPLETE, REFUND.DENIED) return self.status not in (REFUND.COMPLETE, REFUND.DENIED)
@property @property
def can_deny(self): def can_deny(self):
""" """Returns a boolean indicating if this Refund can be denied."""
Returns a boolean indicating if this Refund can be denied.
"""
return self.status == settings.OSCAR_INITIAL_REFUND_STATUS return self.status == settings.OSCAR_INITIAL_REFUND_STATUS
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."""
# 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()
...@@ -102,7 +135,7 @@ class Refund(StatusMixin, TimeStampedModel): ...@@ -102,7 +135,7 @@ class Refund(StatusMixin, TimeStampedModel):
processor.issue_credit(source, self.total_credit_excl_tax, self.currency) processor.issue_credit(source, self.total_credit_excl_tax, self.currency)
def _revoke_lines(self): def _revoke_lines(self):
""" Revoke fulfillment for the lines in this Refund. """ """Revoke fulfillment for the lines in this Refund."""
if revoke_fulfillment_for_refund(self): if revoke_fulfillment_for_refund(self):
self.set_status(REFUND.COMPLETE) self.set_status(REFUND.COMPLETE)
logger.info('Successfully revoked fulfillment for Refund [%d]', self.id) logger.info('Successfully revoked fulfillment for Refund [%d]', self.id)
......
...@@ -26,10 +26,14 @@ class RefundTestMixin(object): ...@@ -26,10 +26,14 @@ 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): def create_order(self, user=None, multiple_lines=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) basket.add_product(self.verified_product)
if multiple_lines:
basket.add_product(self.honor_product)
order = create_order(basket=basket, user=user) order = create_order(basket=basket, user=user)
order.status = ORDER.COMPLETE order.status = ORDER.COMPLETE
order.save() order.save()
...@@ -41,14 +45,15 @@ class RefundTestMixin(object): ...@@ -41,14 +45,15 @@ class RefundTestMixin(object):
self.assertEqual(refund.user, order.user) self.assertEqual(refund.user, order.user)
self.assertEqual(refund.status, settings.OSCAR_INITIAL_REFUND_STATUS) self.assertEqual(refund.status, settings.OSCAR_INITIAL_REFUND_STATUS)
self.assertEqual(refund.total_credit_excl_tax, order.total_excl_tax) self.assertEqual(refund.total_credit_excl_tax, order.total_excl_tax)
self.assertEqual(refund.lines.count(), 1) self.assertEqual(refund.lines.count(), order.lines.count())
refund_line = refund.lines.first() refund_lines = refund.lines.all()
line = order.lines.first() order_lines = order.lines.all().order_by('refund_lines')
self.assertEqual(refund_line.status, settings.OSCAR_INITIAL_REFUND_LINE_STATUS) for refund_line, order_line in zip(refund_lines, order_lines):
self.assertEqual(refund_line.order_line, line) self.assertEqual(refund_line.status, settings.OSCAR_INITIAL_REFUND_LINE_STATUS)
self.assertEqual(refund_line.line_credit_excl_tax, line.line_price_excl_tax) self.assertEqual(refund_line.order_line, order_line)
self.assertEqual(refund_line.quantity, 1) self.assertEqual(refund_line.line_credit_excl_tax, order_line.line_price_excl_tax)
self.assertEqual(refund_line.quantity, order_line.quantity)
def create_refund(self, processor_name='cybersource'): def create_refund(self, processor_name='cybersource'):
refund = RefundFactory() refund = RefundFactory()
......
...@@ -4,6 +4,7 @@ from django.test import TestCase ...@@ -4,6 +4,7 @@ from django.test import TestCase
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
from oscar.test.newfactories import UserFactory
from ecommerce.extensions.refund import models from ecommerce.extensions.refund import models
from ecommerce.extensions.refund.exceptions import InvalidStatus from ecommerce.extensions.refund.exceptions import InvalidStatus
...@@ -72,6 +73,29 @@ class RefundTests(RefundTestMixin, StatusTestsMixin, TestCase): ...@@ -72,6 +73,29 @@ class RefundTests(RefundTestMixin, StatusTestsMixin, TestCase):
""" 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(), self.pipeline.keys()) self.assertEqual(Refund.all_statuses(), self.pipeline.keys())
@ddt.data(False, True)
def test_create_with_lines(self, multiple_lines):
"""
Given an order and order lines that have not been refunded, Refund.create_with_lines
should create a Refund with corresponding RefundLines.
"""
order = self.create_order(user=UserFactory(), multiple_lines=multiple_lines)
refund = Refund.create_with_lines(order, list(order.lines.all()))
self.assert_refund_matches_order(refund, order)
def test_create_with_lines_with_existing_refund(self):
"""
Refund.create_with_lines should not create RefundLines for order lines
which have already been refunded.
"""
order = self.create_order(user=UserFactory())
line = order.lines.first()
RefundLineFactory(order_line=line)
refund = Refund.create_with_lines(order, [line])
self.assertEqual(refund, None)
@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