Commit 6fc5af70 by Clinton Blackburn

Added Refund App

This app will be used for future development of the refund capability. It currently includes models representing refunds and associated line items, as well as basic state maintenance.

XCOM-304
parent f3b8c35c
......@@ -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