diff --git a/registrasion/controllers/invoice.py b/registrasion/controllers/invoice.py index 264049c8..a25221f1 100644 --- a/registrasion/controllers/invoice.py +++ b/registrasion/controllers/invoice.py @@ -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 total_payments(self): + ''' Returns the total amount paid towards this invoice. ''' + + payments = rego.PaymentBase.objects.filter(invoice=self.invoice) + total_paid = payments.aggregate(Sum("amount"))["amount__sum"] or 0 + return total_paid + + def update_status(self): + ''' Updates the status of this invoice based upon the total + payments.''' + + old_status = self.invoice.status + total_paid = self.total_payments() + num_payments = rego.PaymentBase.objects.filter( + invoice=self.invoice, + ).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() + + 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): - ''' 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() + ''' 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.paid: + if self.invoice.status == rego.Invoice.STATUS_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") - - if self.invoice.paid: - raise ValidationError("Paid invoices cannot be paid again") - - ''' Adds a payment ''' - payment = rego.Payment.objects.create( - 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 - - if self.invoice.cart: - cart = self.invoice.cart - cart.active = False - cart.save() - - self.invoice.save() + 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() diff --git a/registrasion/migrations/0013_auto_20160406_2228_squashed_0015_auto_20160406_1942.py b/registrasion/migrations/0013_auto_20160406_2228_squashed_0015_auto_20160406_1942.py new file mode 100644 index 00000000..e4d88313 --- /dev/null +++ b/registrasion/migrations/0013_auto_20160406_2228_squashed_0015_auto_20160406_1942.py @@ -0,0 +1,88 @@ +# -*- 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), + ), + ] diff --git a/registrasion/models.py b/registrasion/models.py index 941ebef5..fedaa68b 100644 --- a/registrasion/models.py +++ b/registrasion/models.py @@ -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 diff --git a/registrasion/tests/controller_helpers.py b/registrasion/tests/controller_helpers.py index 32af58c8..60cf2346 100644 --- a/registrasion/tests/controller_helpers.py +++ b/registrasion/tests/controller_helpers.py @@ -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() diff --git a/registrasion/tests/test_invoice.py b/registrasion/tests/test_invoice.py index 0a7404d6..645dfb80 100644 --- a/registrasion/tests/test_invoice.py +++ b/registrasion/tests/test_invoice.py @@ -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) diff --git a/registrasion/tests/test_refund.py b/registrasion/tests/test_refund.py index 4510cbda..35457749 100644 --- a/registrasion/tests/test_refund.py +++ b/registrasion/tests/test_refund.py @@ -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) diff --git a/registrasion/tests/test_voucher.py b/registrasion/tests/test_voucher.py index 7fa709dc..be598206 100644 --- a/registrasion/tests/test_voucher.py +++ b/registrasion/tests/test_voucher.py @@ -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)