Changeset - 38cdb8aa6330
[Not reviewed]
0 6 1
Christopher Neugebauer - 8 years ago 2016-04-07 07:16:56
chrisjrn@gmail.com
Makes invoice model, controller, and test changes to match issue #15 design doc
7 files changed with 309 insertions and 95 deletions:
0 comments (0 inline, 0 general)
registrasion/controllers/invoice.py
Show inline comments
...
 
@@ -3,6 +3,7 @@ from django.core.exceptions import ObjectDoesNotExist
 
from django.core.exceptions import ValidationError
 
from django.db import transaction
 
from django.db.models import Sum
 
from django.utils import timezone
 

	
 
from registrasion import models as rego
 

	
...
 
@@ -13,6 +14,7 @@ class InvoiceController(object):
 

	
 
    def __init__(self, invoice):
 
        self.invoice = invoice
 
        self.update_status()
 
        self.update_validity()  # Make sure this invoice is up-to-date
 

	
 
    @classmethod
...
 
@@ -22,21 +24,26 @@ class InvoiceController(object):
 
        an invoice is generated.'''
 

	
 
        try:
 
            invoice = rego.Invoice.objects.get(
 
            invoice = rego.Invoice.objects.exclude(
 
                status=rego.Invoice.STATUS_VOID,
 
            ).get(
 
                cart=cart,
 
                cart_revision=cart.revision,
 
                void=False,
 
            )
 
        except ObjectDoesNotExist:
 
            cart_controller = CartController(cart)
 
            cart_controller.validate_cart()  # Raises ValidationError on fail.
 

	
 
            # Void past invoices for this cart
 
            rego.Invoice.objects.filter(cart=cart).update(void=True)
 

	
 
            cls.void_all_invoices(cart)
 
            invoice = cls._generate(cart)
 

	
 
        return InvoiceController(invoice)
 
        return cls(invoice)
 

	
 
    @classmethod
 
    def void_all_invoices(cls, cart):
 
        invoices = rego.Invoice.objects.filter(cart=cart).all()
 
        for invoice in invoices:
 
            cls(invoice).void()
 

	
 
    @classmethod
 
    def resolve_discount_value(cls, item):
...
 
@@ -60,11 +67,21 @@ class InvoiceController(object):
 
    @transaction.atomic
 
    def _generate(cls, cart):
 
        ''' Generates an invoice for the given cart. '''
 

	
 
        issued = timezone.now()
 
        reservation_limit = cart.reservation_duration + cart.time_last_updated
 
        # Never generate a due time that is before the issue time
 
        due = max(issued, reservation_limit)
 

	
 
        invoice = rego.Invoice.objects.create(
 
            user=cart.user,
 
            cart=cart,
 
            cart_revision=cart.revision,
 
            value=Decimal()
 
            status=rego.Invoice.STATUS_UNPAID,
 
            value=Decimal(),
 
            issue_time=issued,
 
            due_time=due,
 
            recipient="BOB_THOMAS", # TODO: add recipient generating code
 
        )
 

	
 
        product_items = rego.ProductItem.objects.filter(cart=cart)
...
 
@@ -84,6 +101,7 @@ class InvoiceController(object):
 
                description="%s - %s" % (product.category.name, product.name),
 
                quantity=item.quantity,
 
                price=product.price,
 
                product=product,
 
            )
 
            invoice_value += line_item.quantity * line_item.price
 

	
...
 
@@ -93,94 +111,121 @@ class InvoiceController(object):
 
                description=item.discount.description,
 
                quantity=item.quantity,
 
                price=cls.resolve_discount_value(item) * -1,
 
                product=item.product,
 
            )
 
            invoice_value += line_item.quantity * line_item.price
 

	
 
        invoice.value = invoice_value
 

	
 
        if invoice.value == 0:
 
            invoice.paid = True
 

	
 
        invoice.save()
 

	
 
        return invoice
 

	
 
    def update_validity(self):
 
        ''' Updates the validity of this invoice if the cart it is attached to
 
        has updated. '''
 
        if self.invoice.cart is not None:
 
            if self.invoice.cart.revision != self.invoice.cart_revision:
 
                self.void()
 
    def total_payments(self):
 
        ''' Returns the total amount paid towards this invoice. '''
 

	
 
    def void(self):
 
        ''' Voids the invoice if it is valid to do so. '''
 
        if self.invoice.paid:
 
            raise ValidationError("Paid invoices cannot be voided, "
 
                                  "only refunded.")
 
        self.invoice.void = True
 
        self.invoice.save()
 

	
 
    @transaction.atomic
 
    def pay(self, reference, amount):
 
        ''' Pays the invoice by the given amount. If the payment
 
        equals the total on the invoice, finalise the invoice.
 
        (NB should be transactional.)
 
        '''
 
        if self.invoice.cart:
 
            cart = CartController(self.invoice.cart)
 
            cart.validate_cart()  # Raises ValidationError if invalid
 

	
 
        if self.invoice.void:
 
            raise ValidationError("Void invoices cannot be paid")
 
        payments = rego.PaymentBase.objects.filter(invoice=self.invoice)
 
        total_paid = payments.aggregate(Sum("amount"))["amount__sum"] or 0
 
        return total_paid
 

	
 
        if self.invoice.paid:
 
            raise ValidationError("Paid invoices cannot be paid again")
 
    def update_status(self):
 
        ''' Updates the status of this invoice based upon the total
 
        payments.'''
 

	
 
        ''' Adds a payment '''
 
        payment = rego.Payment.objects.create(
 
        old_status = self.invoice.status
 
        total_paid = self.total_payments()
 
        num_payments = rego.PaymentBase.objects.filter(
 
            invoice=self.invoice,
 
            reference=reference,
 
            amount=amount,
 
        )
 
        payment.save()
 

	
 
        payments = rego.Payment.objects.filter(invoice=self.invoice)
 
        agg = payments.aggregate(Sum("amount"))
 
        total = agg["amount__sum"]
 

	
 
        if total == self.invoice.value:
 
            self.invoice.paid = True
 
        ).count()
 
        remainder = self.invoice.value - total_paid
 

	
 
        if old_status == rego.Invoice.STATUS_UNPAID:
 
            # Invoice had an amount owing
 
            if remainder <= 0:
 
                # Invoice no longer has amount owing
 
                self._mark_paid()
 
            elif total_paid == 0 and num_payments > 0:
 
                # Invoice has multiple payments totalling zero
 
                self._mark_void()
 
        elif old_status == rego.Invoice.STATUS_PAID:
 
            if remainder > 0:
 
                # Invoice went from having a remainder of zero or less
 
                # to having a positive remainder -- must be a refund
 
                self._mark_refunded()
 
        elif old_status == rego.Invoice.STATUS_REFUNDED:
 
            # Should not ever change from here
 
            pass
 
        elif old_status == rego.Invoice.STATUS_VOID:
 
            # Should not ever change from here
 
            pass
 

	
 
    def _mark_paid(self):
 
        ''' Marks the invoice as paid, and updates the attached cart if
 
        necessary. '''
 
        cart = self.invoice.cart
 
        if cart:
 
            cart.active = False
 
            cart.save()
 
        self.invoice.status = rego.Invoice.STATUS_PAID
 
        self.invoice.save()
 

	
 
            if self.invoice.cart:
 
    def _mark_refunded(self):
 
        ''' Marks the invoice as refunded, and updates the attached cart if
 
        necessary. '''
 
        cart = self.invoice.cart
 
        if cart:
 
            cart.active = False
 
            cart.released = True
 
            cart.save()
 
        self.invoice.status = rego.Invoice.STATUS_REFUNDED
 
        self.invoice.save()
 

	
 
    def _mark_void(self):
 
        ''' Marks the invoice as refunded, and updates the attached cart if
 
        necessary. '''
 
        self.invoice.status = rego.Invoice.STATUS_VOID
 
        self.invoice.save()
 

	
 
    def _invoice_matches_cart(self):
 
        ''' Returns true if there is no cart, or if the revision of this
 
        invoice matches the current revision of the cart. '''
 
        cart = self.invoice.cart
 
        if not cart:
 
            return True
 

	
 
        return cart.revision == self.invoice.cart_revision
 

	
 
    def update_validity(self):
 
        ''' Voids this invoice if the cart it is attached to has updated. '''
 
        if not self._invoice_matches_cart():
 
            self.void()
 

	
 
    def void(self):
 
        ''' Voids the invoice if it is valid to do so. '''
 
        if self.invoice.status == rego.Invoice.STATUS_PAID:
 
            raise ValidationError("Paid invoices cannot be voided, "
 
                                  "only refunded.")
 
        self._mark_void()
 

	
 
    @transaction.atomic
 
    def refund(self, reference, amount):
 
        ''' Refunds the invoice by the given amount. The invoice is
 
        marked as unpaid, and the underlying cart is marked as released.
 
        ''' Refunds the invoice by the given amount.
 

	
 
        The invoice is marked as refunded, and the underlying cart is marked
 
        as released.
 

	
 
        TODO: replace with credit notes work instead.
 
        '''
 

	
 
        if self.invoice.void:
 
        if self.invoice.is_void:
 
            raise ValidationError("Void invoices cannot be refunded")
 

	
 
        ''' Adds a payment '''
 
        payment = rego.Payment.objects.create(
 
        # Adds a payment
 
        # TODO: replace by creating a credit note instead
 
        rego.ManualPayment.objects.create(
 
            invoice=self.invoice,
 
            reference=reference,
 
            amount=0 - amount,
 
        )
 
        payment.save()
 

	
 
        self.invoice.paid = False
 
        self.invoice.void = True
 

	
 
        if self.invoice.cart:
 
            cart = self.invoice.cart
 
            cart.released = True
 
            cart.save()
 

	
 
        self.invoice.save()
 
        self.update_status()
registrasion/migrations/0013_auto_20160406_2228_squashed_0015_auto_20160406_1942.py
Show inline comments
 
new file 100644
 
# -*- coding: utf-8 -*-
 
# Generated by Django 1.9.2 on 2016-04-07 03:13
 
from __future__ import unicode_literals
 

	
 
from django.db import migrations, models
 
import django.db.models.deletion
 
import django.utils.timezone
 

	
 

	
 
class Migration(migrations.Migration):
 

	
 
    replaces = [('registrasion', '0013_auto_20160406_2228'), ('registrasion', '0014_auto_20160406_1847'), ('registrasion', '0015_auto_20160406_1942')]
 

	
 
    dependencies = [
 
        ('registrasion', '0012_auto_20160406_1212'),
 
    ]
 

	
 
    operations = [
 
        migrations.CreateModel(
 
            name='PaymentBase',
 
            fields=[
 
                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
 
                ('time', models.DateTimeField(default=django.utils.timezone.now)),
 
                ('reference', models.CharField(max_length=255)),
 
                ('amount', models.DecimalField(decimal_places=2, max_digits=8)),
 
            ],
 
        ),
 
        migrations.RemoveField(
 
            model_name='payment',
 
            name='invoice',
 
        ),
 
        migrations.RemoveField(
 
            model_name='invoice',
 
            name='paid',
 
        ),
 
        migrations.RemoveField(
 
            model_name='invoice',
 
            name='void',
 
        ),
 
        migrations.AddField(
 
            model_name='invoice',
 
            name='due_time',
 
            field=models.DateTimeField(default=django.utils.timezone.now),
 
            preserve_default=False,
 
        ),
 
        migrations.AddField(
 
            model_name='invoice',
 
            name='issue_time',
 
            field=models.DateTimeField(default=django.utils.timezone.now),
 
            preserve_default=False,
 
        ),
 
        migrations.AddField(
 
            model_name='invoice',
 
            name='recipient',
 
            field=models.CharField(default='Lol', max_length=1024),
 
            preserve_default=False,
 
        ),
 
        migrations.AddField(
 
            model_name='invoice',
 
            name='status',
 
            field=models.IntegerField(choices=[(1, 'Unpaid'), (2, 'Paid'), (3, 'Refunded'), (4, 'VOID')], db_index=True),
 
        ),
 
        migrations.AddField(
 
            model_name='lineitem',
 
            name='product',
 
            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='registrasion.Product'),
 
        ),
 
        migrations.CreateModel(
 
            name='ManualPayment',
 
            fields=[
 
                ('paymentbase_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='registrasion.PaymentBase')),
 
            ],
 
            bases=('registrasion.paymentbase',),
 
        ),
 
        migrations.DeleteModel(
 
            name='Payment',
 
        ),
 
        migrations.AddField(
 
            model_name='paymentbase',
 
            name='invoice',
 
            field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='registrasion.Invoice'),
 
        ),
 
        migrations.AlterField(
 
            model_name='invoice',
 
            name='cart_revision',
 
            field=models.IntegerField(db_index=True, null=True),
 
        ),
 
    ]
registrasion/models.py
Show inline comments
...
 
@@ -3,6 +3,7 @@ from __future__ import unicode_literals
 
import datetime
 
import itertools
 

	
 
from django.core.exceptions import ObjectDoesNotExist
 
from django.core.exceptions import ValidationError
 
from django.contrib.auth.models import User
 
from django.db import models
...
 
@@ -26,13 +27,10 @@ class Attendee(models.Model):
 
    def get_instance(user):
 
        ''' Returns the instance of attendee for the given user, or creates
 
        a new one. '''
 
        attendees = Attendee.objects.filter(user=user)
 
        if len(attendees) > 0:
 
            return attendees[0]
 
        else:
 
            attendee = Attendee(user=user)
 
            attendee.save()
 
            return attendee
 
        try:
 
            return Attendee.objects.get(user=user)
 
        except ObjectDoesNotExist:
 
            return Attendee.objects.create(user=user)
 

	
 
    user = models.OneToOneField(User, on_delete=models.CASCADE)
 
    # Badge/profile is linked
...
 
@@ -54,6 +52,19 @@ class AttendeeProfileBase(models.Model):
 
        speaker profile. If it's None, that functionality is disabled. '''
 
        return None
 

	
 
    def invoice_recipient(self):
 
        ''' Returns a representation of this attendee profile for the purpose
 
        of rendering to an invoice. Override in subclasses. '''
 

	
 
        # Manual dispatch to subclass. Fleh.
 
        slf = AttendeeProfileBase.objects.get_subclass(id=self.id)
 
        # Actually compare the functions.
 
        if type(slf).invoice_recipient != type(self).invoice_recipient:
 
            return type(slf).invoice_recipient(slf)
 

	
 
        # Return a default
 
        return slf.attendee.user.username
 

	
 
    attendee = models.OneToOneField(Attendee, on_delete=models.CASCADE)
 

	
 

	
...
 
@@ -533,6 +544,18 @@ class Invoice(models.Model):
 
    ''' An invoice. Invoices can be automatically generated when checking out
 
    a Cart, in which case, it is attached to a given revision of a Cart. '''
 

	
 
    STATUS_UNPAID = 1
 
    STATUS_PAID = 2
 
    STATUS_REFUNDED = 3
 
    STATUS_VOID = 4
 

	
 
    STATUS_TYPES = [
 
        (STATUS_UNPAID, _("Unpaid")),
 
        (STATUS_PAID, _("Paid")),
 
        (STATUS_REFUNDED, _("Refunded")),
 
        (STATUS_VOID, _("VOID")),
 
    ]
 

	
 
    def __str__(self):
 
        return "Invoice #%d" % self.id
 

	
...
 
@@ -541,13 +564,37 @@ class Invoice(models.Model):
 
            raise ValidationError(
 
                "If this is a cart invoice, it must have a revision")
 

	
 
    @property
 
    def is_unpaid(self):
 
        return self.status == self.STATUS_UNPAID
 

	
 
    @property
 
    def is_void(self):
 
        return self.status == self.STATUS_VOID
 

	
 
    @property
 
    def is_paid(self):
 
        return self.status == self.STATUS_PAID
 

	
 
    @property
 
    def is_refunded(self):
 
        return self.status == self.STATUS_REFUNDED
 

	
 
    # Invoice Number
 
    user = models.ForeignKey(User)
 
    cart = models.ForeignKey(Cart, null=True)
 
    cart_revision = models.IntegerField(null=True)
 
    cart_revision = models.IntegerField(
 
        null=True,
 
        db_index=True,
 
    )
 
    # Line Items (foreign key)
 
    void = models.BooleanField(default=False)
 
    paid = models.BooleanField(default=False)
 
    status = models.IntegerField(
 
        choices=STATUS_TYPES,
 
        db_index=True,
 
    )
 
    recipient = models.CharField(max_length=1024)
 
    issue_time = models.DateTimeField()
 
    due_time = models.DateTimeField()
 
    value = models.DecimalField(max_digits=8, decimal_places=2)
 

	
 

	
...
 
@@ -565,17 +612,25 @@ class LineItem(models.Model):
 
    description = models.CharField(max_length=255)
 
    quantity = models.PositiveIntegerField()
 
    price = models.DecimalField(max_digits=8, decimal_places=2)
 
    product = models.ForeignKey(Product, null=True, blank=True)
 

	
 

	
 
@python_2_unicode_compatible
 
class Payment(models.Model):
 
    ''' A payment for an invoice. Each invoice can have multiple payments
 
    attached to it.'''
 
class PaymentBase(models.Model):
 
    ''' The base payment type for invoices. Payment apps should subclass this
 
    class to handle implementation-specific issues. '''
 

	
 
    objects = InheritanceManager()
 

	
 
    def __str__(self):
 
        return "Payment: ref=%s amount=%s" % (self.reference, self.amount)
 

	
 
    invoice = models.ForeignKey(Invoice)
 
    time = models.DateTimeField(default=timezone.now)
 
    reference = models.CharField(max_length=64)
 
    reference = models.CharField(max_length=255)
 
    amount = models.DecimalField(max_digits=8, decimal_places=2)
 

	
 

	
 
class ManualPayment(PaymentBase):
 
    ''' Payments that are manually entered by staff. '''
 
    pass
registrasion/tests/controller_helpers.py
Show inline comments
...
 
@@ -3,6 +3,7 @@ from registrasion.controllers.invoice import InvoiceController
 
from registrasion import models as rego
 

	
 
from django.core.exceptions import ObjectDoesNotExist
 
from django.core.exceptions import ValidationError
 

	
 

	
 
class TestingCartController(CartController):
...
 
@@ -32,4 +33,27 @@ class TestingCartController(CartController):
 

	
 

	
 
class TestingInvoiceController(InvoiceController):
 
    pass
 

	
 
    def pay(self, reference, amount):
 
        ''' Testing method for simulating an invoice paymenht by the given
 
        amount. '''
 
        if self.invoice.cart:
 
            cart = CartController(self.invoice.cart)
 
            cart.validate_cart()  # Raises ValidationError if invalid
 

	
 
        status = self.invoice.status
 
        if status == rego.Invoice.STATUS_VOID:
 
            raise ValidationError("Void invoices cannot be paid")
 
        elif status == rego.Invoice.STATUS_PAID:
 
            raise ValidationError("Paid invoices cannot be paid again")
 
        elif status == rego.Invoice.STATUS_REFUNDED:
 
            raise ValidationError("Refunded invoices cannot be paid")
 

	
 
        ''' Adds a payment '''
 
        payment = rego.ManualPayment.objects.create(
 
            invoice=self.invoice,
 
            reference=reference,
 
            amount=amount,
 
        )
 

	
 
        self.update_status()
registrasion/tests/test_invoice.py
Show inline comments
...
 
@@ -35,8 +35,8 @@ class InvoiceTestCase(RegistrationCartTestCase):
 
        # The old invoice should automatically be voided
 
        invoice_1_new = rego.Invoice.objects.get(pk=invoice_1.invoice.id)
 
        invoice_2_new = rego.Invoice.objects.get(pk=invoice_2.invoice.id)
 
        self.assertTrue(invoice_1_new.void)
 
        self.assertFalse(invoice_2_new.void)
 
        self.assertTrue(invoice_1_new.is_void)
 
        self.assertFalse(invoice_2_new.is_void)
 

	
 
        # Invoice should have two line items
 
        line_items = rego.LineItem.objects.filter(invoice=invoice_2.invoice)
...
 
@@ -68,7 +68,7 @@ class InvoiceTestCase(RegistrationCartTestCase):
 
        invoice.pay("A payment!", invoice.invoice.value)
 

	
 
        # This payment is for the correct amount invoice should be paid.
 
        self.assertTrue(invoice.invoice.paid)
 
        self.assertTrue(invoice.invoice.is_paid)
 

	
 
        # Cart should not be active
 
        self.assertFalse(invoice.invoice.cart.active)
...
 
@@ -133,7 +133,7 @@ class InvoiceTestCase(RegistrationCartTestCase):
 
        current_cart.add_to_cart(self.PROD_1, 1)
 
        invoice_1 = TestingInvoiceController.for_cart(current_cart.cart)
 

	
 
        self.assertTrue(invoice_1.invoice.paid)
 
        self.assertTrue(invoice_1.invoice.is_paid)
 

	
 
    def test_invoice_voids_self_if_cart_is_invalid(self):
 
        current_cart = TestingCartController.for_user(self.USER_1)
...
 
@@ -142,7 +142,7 @@ class InvoiceTestCase(RegistrationCartTestCase):
 
        current_cart.add_to_cart(self.PROD_1, 1)
 
        invoice_1 = TestingInvoiceController.for_cart(current_cart.cart)
 

	
 
        self.assertFalse(invoice_1.invoice.void)
 
        self.assertFalse(invoice_1.invoice.is_void)
 

	
 
        # Adding item to cart should produce a new invoice
 
        current_cart.add_to_cart(self.PROD_2, 1)
...
 
@@ -151,11 +151,11 @@ class InvoiceTestCase(RegistrationCartTestCase):
 

	
 
        # Viewing invoice_1's invoice should show it as void
 
        invoice_1_new = TestingInvoiceController(invoice_1.invoice)
 
        self.assertTrue(invoice_1_new.invoice.void)
 
        self.assertTrue(invoice_1_new.invoice.is_void)
 

	
 
        # Viewing invoice_2's invoice should *not* show it as void
 
        invoice_2_new = TestingInvoiceController(invoice_2.invoice)
 
        self.assertFalse(invoice_2_new.invoice.void)
 
        self.assertFalse(invoice_2_new.invoice.is_void)
 

	
 
    def test_voiding_invoice_creates_new_invoice(self):
 
        current_cart = TestingCartController.for_user(self.USER_1)
...
 
@@ -164,7 +164,7 @@ class InvoiceTestCase(RegistrationCartTestCase):
 
        current_cart.add_to_cart(self.PROD_1, 1)
 
        invoice_1 = TestingInvoiceController.for_cart(current_cart.cart)
 

	
 
        self.assertFalse(invoice_1.invoice.void)
 
        self.assertFalse(invoice_1.invoice.is_void)
 
        invoice_1.void()
 

	
 
        invoice_2 = TestingInvoiceController.for_cart(current_cart.cart)
registrasion/tests/test_refund.py
Show inline comments
...
 
@@ -18,11 +18,13 @@ class RefundTestCase(RegistrationCartTestCase):
 
        invoice = TestingInvoiceController.for_cart(current_cart.cart)
 

	
 
        invoice.pay("A Payment!", invoice.invoice.value)
 
        self.assertFalse(invoice.invoice.void)
 
        self.assertTrue(invoice.invoice.paid)
 
        self.assertFalse(invoice.invoice.is_void)
 
        self.assertTrue(invoice.invoice.is_paid)
 
        self.assertFalse(invoice.invoice.is_refunded)
 
        self.assertFalse(invoice.invoice.cart.released)
 

	
 
        invoice.refund("A Refund!", invoice.invoice.value)
 
        self.assertTrue(invoice.invoice.void)
 
        self.assertFalse(invoice.invoice.paid)
 
        self.assertFalse(invoice.invoice.is_void)
 
        self.assertFalse(invoice.invoice.is_paid)
 
        self.assertTrue(invoice.invoice.is_refunded)
 
        self.assertTrue(invoice.invoice.cart.released)
registrasion/tests/test_voucher.py
Show inline comments
...
 
@@ -141,7 +141,7 @@ class VoucherTestCases(RegistrationCartTestCase):
 
        current_cart.add_to_cart(self.PROD_1, 1)
 

	
 
        inv = TestingInvoiceController.for_cart(current_cart.cart)
 
        if not inv.invoice.paid:
 
        if not inv.invoice.is_paid:
 
            inv.pay("Hello!", inv.invoice.value)
 

	
 
        current_cart = TestingCartController.for_user(self.USER_1)
0 comments (0 inline, 0 general)