Commit eda7f56e by Clinton Blackburn

Merge pull request #91 from edx/refund-models

Added Refund App
parents cb2e94aa 6fc5af70
......@@ -13,6 +13,10 @@ omit = ecommerce/settings*
ecommerce/extensions/payment/constants*
ecommerce/extensions/payment/models*
ecommerce/extensions/refund/exceptions*
ecommerce/extensions/refund/models*
ecommerce/extensions/refund/status*
# The fulfillment app's status module only contains constants, which don't require
# test coverage.
ecommerce/extensions/fulfillment/status.py
......
class InvalidStatus(Exception):
""" Base class for invalid status errors. """
pass
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
import ecommerce.extensions.refund.models
import django.db.models.deletion
from django.conf import settings
class Migration(migrations.Migration):
dependencies = [
('order', '0008_delete_order_payment_processor'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='HistoricalRefund',
fields=[
('id', models.IntegerField(verbose_name='ID', db_index=True, auto_created=True, blank=True)),
('total_credit_excl_tax', models.DecimalField(verbose_name='Total Credit (excl. tax)', max_digits=12, decimal_places=2)),
('status', models.CharField(max_length=255, verbose_name='Status')),
('history_id', models.AutoField(serialize=False, primary_key=True)),
('history_date', models.DateTimeField()),
('history_type', models.CharField(max_length=1, choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')])),
('history_user', models.ForeignKey(related_name='+', on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, null=True)),
('order', models.ForeignKey(related_name='+', on_delete=django.db.models.deletion.DO_NOTHING, db_constraint=False, blank=True, to='order.Order', null=True)),
('user', models.ForeignKey(related_name='+', on_delete=django.db.models.deletion.DO_NOTHING, db_constraint=False, blank=True, to=settings.AUTH_USER_MODEL, null=True)),
],
options={
'ordering': ('-history_date', '-history_id'),
'get_latest_by': 'history_date',
'verbose_name': 'historical refund',
},
bases=(models.Model,),
),
migrations.CreateModel(
name='HistoricalRefundLine',
fields=[
('id', models.IntegerField(verbose_name='ID', db_index=True, auto_created=True, blank=True)),
('line_credit_excl_tax', models.DecimalField(verbose_name='Line Credit (excl. tax)', max_digits=12, decimal_places=2)),
('quantity', models.PositiveIntegerField(default=1, verbose_name='Quantity')),
('status', models.CharField(max_length=255, verbose_name='Status')),
('history_id', models.AutoField(serialize=False, primary_key=True)),
('history_date', models.DateTimeField()),
('history_type', models.CharField(max_length=1, choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')])),
('history_user', models.ForeignKey(related_name='+', on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, null=True)),
('order_line', models.ForeignKey(related_name='+', on_delete=django.db.models.deletion.DO_NOTHING, db_constraint=False, blank=True, to='order.Line', null=True)),
],
options={
'ordering': ('-history_date', '-history_id'),
'get_latest_by': 'history_date',
'verbose_name': 'historical refund line',
},
bases=(models.Model,),
),
migrations.CreateModel(
name='Refund',
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('total_credit_excl_tax', models.DecimalField(verbose_name='Total Credit (excl. tax)', max_digits=12, decimal_places=2)),
('status', models.CharField(max_length=255, verbose_name='Status')),
('order', models.ForeignKey(related_name='refund', verbose_name='Order', to='order.Order')),
('user', models.ForeignKey(related_name='refunds', verbose_name='User', to=settings.AUTH_USER_MODEL)),
],
options={
},
bases=(ecommerce.extensions.refund.models.StatusMixin, models.Model),
),
migrations.CreateModel(
name='RefundLine',
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('line_credit_excl_tax', models.DecimalField(verbose_name='Line Credit (excl. tax)', max_digits=12, decimal_places=2)),
('quantity', models.PositiveIntegerField(default=1, verbose_name='Quantity')),
('status', models.CharField(max_length=255, verbose_name='Status')),
('order_line', models.ForeignKey(related_name='refund_lines', verbose_name='Order Line', to='order.Line')),
('refund', models.ForeignKey(related_name='lines', verbose_name='Refund', to='refund.Refund')),
],
options={
},
bases=(ecommerce.extensions.refund.models.StatusMixin, models.Model),
),
migrations.AddField(
model_name='historicalrefundline',
name='refund',
field=models.ForeignKey(related_name='+', on_delete=django.db.models.deletion.DO_NOTHING, db_constraint=False, blank=True, to='refund.Refund', null=True),
preserve_default=True,
),
]
from django.conf import settings
from django.db import models
from django.utils.translation import ugettext_lazy as _
from simple_history.models import HistoricalRecords
from ecommerce.extensions.refund.exceptions import InvalidStatus
class StatusMixin(object):
pipeline_setting = None
@property
def pipeline(self):
# NOTE: We use the property and getattr (instead of settings.XXX) so that we can properly override the
# settings when testing.
return getattr(settings, self.pipeline_setting)
def available_statuses(self):
""" Returns all possible statuses that this object can move to. """
return self.pipeline.get(self.status, ())
# pylint: disable=access-member-before-definition,attribute-defined-outside-init
def set_status(self, new_status):
"""
Set a new status for this object.
If the requested status is not valid, then ``InvalidStatus`` is raised.
"""
if new_status not in self.available_statuses():
msg = " Transition from '{status}' to '{new_status}' is invalid for {model_name} {id}.".format(
new_status=new_status,
model_name=self.__class__.__name__.lower(),
id=self.id,
status=self.status
)
raise InvalidStatus(msg)
self.status = new_status
self.save()
class Refund(StatusMixin, models.Model):
"""Main refund model, used to represent the state of a refund."""
order = models.ForeignKey('order.Order', related_name='refund', verbose_name=_('Order'))
user = models.ForeignKey('user.User', related_name='refunds', verbose_name=_('User'))
total_credit_excl_tax = models.DecimalField(_('Total Credit (excl. tax)'), decimal_places=2, max_digits=12)
status = models.CharField(_('Status'), max_length=255)
history = HistoricalRecords()
pipeline_setting = 'OSCAR_REFUND_STATUS_PIPELINE'
class RefundLine(StatusMixin, models.Model):
"""A refund line, used to represent the state of a single item as part of a larger Refund."""
refund = models.ForeignKey('refund.Refund', related_name='lines', verbose_name=_('Refund'))
order_line = models.ForeignKey('order.Line', related_name='refund_lines', verbose_name=_('Order Line'))
line_credit_excl_tax = models.DecimalField(_('Line Credit (excl. tax)'), decimal_places=2, max_digits=12)
quantity = models.PositiveIntegerField(_('Quantity'), default=1)
status = models.CharField(_('Status'), max_length=255)
history = HistoricalRecords()
pipeline_setting = 'OSCAR_REFUND_LINE_STATUS_PIPELINE'
class REFUND(object):
OPEN = 'Open'
DENIED = 'Denied'
ERROR = 'Error'
COMPLETE = 'Complete'
class REFUND_LINE(object):
OPEN = 'Open'
PAYMENT_REFUND_ERROR = 'Payment Refund Error'
PAYMENT_REFUNDED = 'Payment Refunded'
REVOCATION_ERROR = 'Revocation Error'
DENIED = 'Denied'
COMPLETE = 'Complete'
from decimal import Decimal
from django.conf import settings
import factory
from oscar.core.loading import get_model
from oscar.test import factories
from oscar.test.newfactories import UserFactory
from ecommerce.extensions.refund.status import REFUND, REFUND_LINE
class RefundFactory(factory.DjangoModelFactory):
status = getattr(settings, 'OSCAR_INITIAL_REFUND_STATUS', REFUND.OPEN)
user = factory.SubFactory(UserFactory)
total_credit_excl_tax = Decimal(1.00)
@factory.lazy_attribute
def order(self):
return factories.create_order(user=self.user)
class Meta(object):
model = get_model('refund', 'Refund')
class RefundLineFactory(factory.DjangoModelFactory):
status = getattr(settings, 'OSCAR_INITIAL_REFUND_LINE_STATUS', REFUND_LINE.OPEN)
refund = factory.SubFactory(RefundFactory)
line_credit_excl_tax = Decimal(1.00)
@factory.lazy_attribute
def order_line(self):
order = factories.create_order()
return order.lines.first()
class Meta(object):
model = get_model('refund', 'RefundLine')
from django.test import TestCase, override_settings
from ecommerce.extensions.refund.exceptions import InvalidStatus
from ecommerce.extensions.refund.status import REFUND, REFUND_LINE
from ecommerce.extensions.refund.tests.factories import RefundFactory, RefundLineFactory
OSCAR_REFUND_STATUS_PIPELINE = {
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: ()
}
class StatusTestsMixin(object):
pipeline = None
def _get_instance(self, **kwargs):
""" Generate an instance of the model being tested. """
raise NotImplementedError
def test_available_statuses(self):
""" Verify available_statuses() returns a list of statuses corresponding to the pipeline. """
for status, allowed_transitions in self.pipeline.iteritems():
instance = self._get_instance(status=status)
self.assertEqual(instance.available_statuses(), allowed_transitions)
def test_set_status_invalid_status(self):
""" Verify attempts to set the status to an invalid value raise an exception. """
for status, valid_statuses in self.pipeline.iteritems():
instance = self._get_instance(status=status)
all_statuses = self.pipeline.keys()
invalid_statuses = set(all_statuses) - set(valid_statuses)
for new_status in invalid_statuses:
self.assertRaises(InvalidStatus, instance.set_status, new_status)
self.assertEqual(instance.status, status,
'Refund status should not be changed when attempting to set an invalid status.')
def test_set_status_valid_status(self):
""" Verify status is updated when attempting to transition to a valid status. """
for status, valid_statuses in self.pipeline.iteritems():
for new_status in valid_statuses:
instance = self._get_instance(status=status)
instance.set_status(new_status)
self.assertEqual(instance.status, new_status, 'Refund status was not updated!')
@override_settings(OSCAR_REFUND_STATUS_PIPELINE=OSCAR_REFUND_STATUS_PIPELINE)
class RefundTests(StatusTestsMixin, TestCase):
pipeline = OSCAR_REFUND_STATUS_PIPELINE
def _get_instance(self, **kwargs):
return RefundFactory(**kwargs)
@override_settings(OSCAR_REFUND_LINE_STATUS_PIPELINE=OSCAR_REFUND_LINE_STATUS_PIPELINE)
class RefundLineTests(StatusTestsMixin, TestCase):
pipeline = OSCAR_REFUND_LINE_STATUS_PIPELINE
def _get_instance(self, **kwargs):
return RefundLineFactory(**kwargs)
......@@ -8,6 +8,7 @@ from oscar.defaults import *
from oscar import get_core_apps
from ecommerce.extensions.fulfillment.status import ORDER, LINE
from ecommerce.extensions.refund.status import REFUND, REFUND_LINE
# URL CONFIGURATION
......@@ -19,6 +20,7 @@ OSCAR_HOMEPAGE = reverse_lazy('dashboard:index')
OSCAR_APPS = [
'ecommerce.extensions.api',
'ecommerce.extensions.fulfillment',
'ecommerce.extensions.refund',
] + get_core_apps([
'ecommerce.extensions.analytics',
'ecommerce.extensions.catalogue',
......@@ -122,3 +124,25 @@ PAYMENT_PROCESSOR_CONFIG = {
# queries which significantly degrade performance at scale.
INSTALL_DEFAULT_ANALYTICS_RECEIVERS = False
# END ANALYTICS
# REFUND PROCESSING
OSCAR_INITIAL_REFUND_STATUS = REFUND.OPEN
OSCAR_INITIAL_REFUND_LINE_STATUS = REFUND_LINE.OPEN
OSCAR_REFUND_STATUS_PIPELINE = {
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_REFUND_ERROR),
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.COMPLETE: ()
}
# END REFUND PROCESSING
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