Makes invoice model, controller, and test changes to match issue #15 design doc

This commit is contained in:
Christopher Neugebauer 2016-04-07 08:28:43 +10:00
parent 5633554854
commit 38cdb8aa63
7 changed files with 313 additions and 99 deletions

View file

@ -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:
''' 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()

View file

@ -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),
),
]

View file

@ -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

View file

@ -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()

View file

@ -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)

View file

@ -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)

View file

@ -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)