Merge branch 'invoices_and_payments'

This commit is contained in:
Christopher Neugebauer 2016-04-08 17:08:10 +10:00
commit a12460e351
18 changed files with 578 additions and 141 deletions

View file

@ -3,6 +3,7 @@ from django.core.exceptions import ObjectDoesNotExist
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import transaction from django.db import transaction
from django.db.models import Sum from django.db.models import Sum
from django.utils import timezone
from registrasion import models as rego from registrasion import models as rego
@ -13,6 +14,7 @@ class InvoiceController(object):
def __init__(self, invoice): def __init__(self, invoice):
self.invoice = invoice self.invoice = invoice
self.update_status()
self.update_validity() # Make sure this invoice is up-to-date self.update_validity() # Make sure this invoice is up-to-date
@classmethod @classmethod
@ -22,21 +24,26 @@ class InvoiceController(object):
an invoice is generated.''' an invoice is generated.'''
try: try:
invoice = rego.Invoice.objects.get( invoice = rego.Invoice.objects.exclude(
status=rego.Invoice.STATUS_VOID,
).get(
cart=cart, cart=cart,
cart_revision=cart.revision, cart_revision=cart.revision,
void=False,
) )
except ObjectDoesNotExist: except ObjectDoesNotExist:
cart_controller = CartController(cart) cart_controller = CartController(cart)
cart_controller.validate_cart() # Raises ValidationError on fail. cart_controller.validate_cart() # Raises ValidationError on fail.
# Void past invoices for this cart cls.void_all_invoices(cart)
rego.Invoice.objects.filter(cart=cart).update(void=True)
invoice = cls._generate(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 @classmethod
def resolve_discount_value(cls, item): def resolve_discount_value(cls, item):
@ -60,11 +67,26 @@ class InvoiceController(object):
@transaction.atomic @transaction.atomic
def _generate(cls, cart): def _generate(cls, cart):
''' Generates an invoice for the given 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)
# Get the invoice recipient
profile = rego.AttendeeProfileBase.objects.get_subclass(
id=cart.user.attendee.attendeeprofilebase.id,
)
recipient = profile.invoice_recipient()
invoice = rego.Invoice.objects.create( invoice = rego.Invoice.objects.create(
user=cart.user, user=cart.user,
cart=cart, cart=cart,
cart_revision=cart.revision, cart_revision=cart.revision,
value=Decimal() status=rego.Invoice.STATUS_UNPAID,
value=Decimal(),
issue_time=issued,
due_time=due,
recipient=recipient,
) )
product_items = rego.ProductItem.objects.filter(cart=cart) product_items = rego.ProductItem.objects.filter(cart=cart)
@ -84,6 +106,7 @@ class InvoiceController(object):
description="%s - %s" % (product.category.name, product.name), description="%s - %s" % (product.category.name, product.name),
quantity=item.quantity, quantity=item.quantity,
price=product.price, price=product.price,
product=product,
) )
invoice_value += line_item.quantity * line_item.price invoice_value += line_item.quantity * line_item.price
@ -93,94 +116,162 @@ class InvoiceController(object):
description=item.discount.description, description=item.discount.description,
quantity=item.quantity, quantity=item.quantity,
price=cls.resolve_discount_value(item) * -1, price=cls.resolve_discount_value(item) * -1,
product=item.product,
) )
invoice_value += line_item.quantity * line_item.price invoice_value += line_item.quantity * line_item.price
invoice.value = invoice_value invoice.value = invoice_value
if invoice.value == 0:
invoice.paid = True
invoice.save() invoice.save()
return invoice return invoice
def can_view(self, user=None, access_code=None):
''' Returns true if the accessing user is allowed to view this invoice,
or if the given access code matches this invoice's user's access code.
'''
if user == self.invoice.user:
return True
if user.is_staff:
return True
if self.invoice.user.attendee.access_code == access_code:
return True
return False
def _refresh(self):
''' Refreshes the underlying invoice and cart objects. '''
self.invoice.refresh_from_db()
if self.invoice.cart:
self.invoice.cart.refresh_from_db()
def validate_allowed_to_pay(self):
''' Passes cleanly if we're allowed to pay, otherwise raise
a ValidationError. '''
self._refresh()
if not self.invoice.is_unpaid:
raise ValidationError("You can only pay for unpaid invoices.")
if not self.invoice.cart:
return
if not self._invoice_matches_cart():
raise ValidationError("The registration has been amended since "
"generating this invoice.")
CartController(self.invoice.cart).validate_cart()
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): def update_validity(self):
''' Updates the validity of this invoice if the cart it is attached to ''' Voids this invoice if the cart it is attached to has updated. '''
has updated. ''' if not self._invoice_matches_cart():
if self.invoice.cart is not None:
if self.invoice.cart.revision != self.invoice.cart_revision:
self.void() self.void()
def void(self): def void(self):
''' Voids the invoice if it is valid to do so. ''' ''' 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, " raise ValidationError("Paid invoices cannot be voided, "
"only refunded.") "only refunded.")
self.invoice.void = True self._mark_void()
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()
@transaction.atomic @transaction.atomic
def refund(self, reference, amount): def refund(self, reference, amount):
''' Refunds the invoice by the given amount. The invoice is ''' Refunds the invoice by the given amount.
marked as unpaid, and the underlying cart is marked as released.
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") raise ValidationError("Void invoices cannot be refunded")
''' Adds a payment ''' # Adds a payment
payment = rego.Payment.objects.create( # TODO: replace by creating a credit note instead
rego.ManualPayment.objects.create(
invoice=self.invoice, invoice=self.invoice,
reference=reference, reference=reference,
amount=0 - amount, amount=0 - amount,
) )
payment.save()
self.invoice.paid = False self.update_status()
self.invoice.void = True
if self.invoice.cart:
cart = self.invoice.cart
cart.released = True
cart.save()
self.invoice.save()

View file

@ -3,6 +3,13 @@ import models as rego
from django import forms from django import forms
class ManualPaymentForm(forms.ModelForm):
class Meta:
model = rego.ManualPayment
fields = ["reference", "amount"]
# Products forms -- none of these have any fields: they are to be subclassed # Products forms -- none of these have any fields: they are to be subclassed
# and the fields added as needs be. # and the fields added as needs be.

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

@ -0,0 +1,21 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.9.2 on 2016-04-08 02:20
from __future__ import unicode_literals
from django.db import migrations, models
import registrasion.util
class Migration(migrations.Migration):
dependencies = [
('registrasion', '0013_auto_20160406_2228_squashed_0015_auto_20160406_1942'),
]
operations = [
migrations.AddField(
model_name='attendee',
name='access_code',
field=models.CharField(default=registrasion.util.generate_access_code, max_length=6, unique=True),
),
]

View file

@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.9.2 on 2016-04-08 02:20
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('registrasion', '0014_attendee_access_code'),
]
operations = [
migrations.AlterField(
model_name='attendee',
name='access_code',
field=models.CharField(max_length=6, unique=True),
),
]

View file

@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.9.2 on 2016-04-08 02:34
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('registrasion', '0015_auto_20160408_0220'),
]
operations = [
migrations.AlterField(
model_name='attendee',
name='access_code',
field=models.CharField(db_index=True, max_length=6, unique=True),
),
]

View file

@ -1,8 +1,11 @@
from __future__ import unicode_literals from __future__ import unicode_literals
import util
import datetime import datetime
import itertools import itertools
from django.core.exceptions import ObjectDoesNotExist
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.db import models from django.db import models
@ -26,16 +29,25 @@ class Attendee(models.Model):
def get_instance(user): def get_instance(user):
''' Returns the instance of attendee for the given user, or creates ''' Returns the instance of attendee for the given user, or creates
a new one. ''' a new one. '''
attendees = Attendee.objects.filter(user=user) try:
if len(attendees) > 0: return Attendee.objects.get(user=user)
return attendees[0] except ObjectDoesNotExist:
else: return Attendee.objects.create(user=user)
attendee = Attendee(user=user)
attendee.save() def save(self, *a, **k):
return attendee while not self.access_code:
access_code = util.generate_access_code()
if Attendee.objects.filter(access_code=access_code).count() == 0:
self.access_code = access_code
return super(Attendee, self).save(*a, **k)
user = models.OneToOneField(User, on_delete=models.CASCADE) user = models.OneToOneField(User, on_delete=models.CASCADE)
# Badge/profile is linked # Badge/profile is linked
access_code = models.CharField(
max_length=6,
unique=True,
db_index=True,
)
completed_registration = models.BooleanField(default=False) completed_registration = models.BooleanField(default=False)
highest_complete_category = models.IntegerField(default=0) highest_complete_category = models.IntegerField(default=0)
@ -54,6 +66,19 @@ class AttendeeProfileBase(models.Model):
speaker profile. If it's None, that functionality is disabled. ''' speaker profile. If it's None, that functionality is disabled. '''
return None 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) attendee = models.OneToOneField(Attendee, on_delete=models.CASCADE)
@ -533,6 +558,18 @@ class Invoice(models.Model):
''' An invoice. Invoices can be automatically generated when checking out ''' 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. ''' 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): def __str__(self):
return "Invoice #%d" % self.id return "Invoice #%d" % self.id
@ -541,13 +578,37 @@ class Invoice(models.Model):
raise ValidationError( raise ValidationError(
"If this is a cart invoice, it must have a revision") "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 # Invoice Number
user = models.ForeignKey(User) user = models.ForeignKey(User)
cart = models.ForeignKey(Cart, null=True) 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) # Line Items (foreign key)
void = models.BooleanField(default=False) status = models.IntegerField(
paid = models.BooleanField(default=False) 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) value = models.DecimalField(max_digits=8, decimal_places=2)
@ -565,17 +626,25 @@ class LineItem(models.Model):
description = models.CharField(max_length=255) description = models.CharField(max_length=255)
quantity = models.PositiveIntegerField() quantity = models.PositiveIntegerField()
price = models.DecimalField(max_digits=8, decimal_places=2) price = models.DecimalField(max_digits=8, decimal_places=2)
product = models.ForeignKey(Product, null=True, blank=True)
@python_2_unicode_compatible @python_2_unicode_compatible
class Payment(models.Model): class PaymentBase(models.Model):
''' A payment for an invoice. Each invoice can have multiple payments ''' The base payment type for invoices. Payment apps should subclass this
attached to it.''' class to handle implementation-specific issues. '''
objects = InheritanceManager()
def __str__(self): def __str__(self):
return "Payment: ref=%s amount=%s" % (self.reference, self.amount) return "Payment: ref=%s amount=%s" % (self.reference, self.amount)
invoice = models.ForeignKey(Invoice) invoice = models.ForeignKey(Invoice)
time = models.DateTimeField(default=timezone.now) 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) amount = models.DecimalField(max_digits=8, decimal_places=2)
class ManualPayment(PaymentBase):
''' Payments that are manually entered by staff. '''
pass

View file

@ -1,7 +1,9 @@
from registrasion.controllers.cart import CartController from registrasion.controllers.cart import CartController
from registrasion.controllers.invoice import InvoiceController
from registrasion import models as rego from registrasion import models as rego
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
from django.core.exceptions import ValidationError
class TestingCartController(CartController): class TestingCartController(CartController):
@ -28,3 +30,21 @@ class TestingCartController(CartController):
def next_cart(self): def next_cart(self):
self.cart.active = False self.cart.active = False
self.cart.save() self.cart.save()
class TestingInvoiceController(InvoiceController):
def pay(self, reference, amount):
''' Testing method for simulating an invoice paymenht by the given
amount. '''
self.validate_allowed_to_pay()
''' Adds a payment '''
payment = rego.ManualPayment.objects.create(
invoice=self.invoice,
reference=reference,
amount=amount,
)
self.update_status()

View file

@ -10,7 +10,7 @@ from django.test import TestCase
from registrasion import models as rego from registrasion import models as rego
from registrasion.controllers.product import ProductController from registrasion.controllers.product import ProductController
from cart_controller_helper import TestingCartController from controller_helpers import TestingCartController
from patch_datetime import SetTimeMixin from patch_datetime import SetTimeMixin
UTC = pytz.timezone('UTC') UTC = pytz.timezone('UTC')
@ -23,6 +23,9 @@ class RegistrationCartTestCase(SetTimeMixin, TestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
super(RegistrationCartTestCase, cls).setUpTestData()
cls.USER_1 = User.objects.create_user( cls.USER_1 = User.objects.create_user(
username='testuser', username='testuser',
email='test@example.com', email='test@example.com',
@ -33,6 +36,15 @@ class RegistrationCartTestCase(SetTimeMixin, TestCase):
email='test2@example.com', email='test2@example.com',
password='top_secret') password='top_secret')
attendee1 = rego.Attendee.get_instance(cls.USER_1)
attendee1.save()
profile1 = rego.AttendeeProfileBase.objects.create(attendee=attendee1)
profile1.save()
attendee2 = rego.Attendee.get_instance(cls.USER_2)
attendee2.save()
profile2 = rego.AttendeeProfileBase.objects.create(attendee=attendee2)
profile2.save()
cls.RESERVATION = datetime.timedelta(hours=1) cls.RESERVATION = datetime.timedelta(hours=1)
cls.categories = [] cls.categories = []
@ -136,6 +148,10 @@ class RegistrationCartTestCase(SetTimeMixin, TestCase):
voucher.save() voucher.save()
return voucher return voucher
@classmethod
def reget(cls, object):
return type(object).objects.get(id=object.id)
class BasicCartTests(RegistrationCartTestCase): class BasicCartTests(RegistrationCartTestCase):

View file

@ -3,7 +3,7 @@ import pytz
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from cart_controller_helper import TestingCartController from controller_helpers import TestingCartController
from test_cart import RegistrationCartTestCase from test_cart import RegistrationCartTestCase
from registrasion import models as rego from registrasion import models as rego

View file

@ -4,7 +4,8 @@ from decimal import Decimal
from registrasion import models as rego from registrasion import models as rego
from registrasion.controllers import discount from registrasion.controllers import discount
from cart_controller_helper import TestingCartController from controller_helpers import TestingCartController
from controller_helpers import TestingInvoiceController
from test_cart import RegistrationCartTestCase from test_cart import RegistrationCartTestCase

View file

@ -4,7 +4,7 @@ from django.core.exceptions import ValidationError
from registrasion import models as rego from registrasion import models as rego
from registrasion.controllers.category import CategoryController from registrasion.controllers.category import CategoryController
from cart_controller_helper import TestingCartController from controller_helpers import TestingCartController
from registrasion.controllers.product import ProductController from registrasion.controllers.product import ProductController
from test_cart import RegistrationCartTestCase from test_cart import RegistrationCartTestCase

View file

@ -5,8 +5,8 @@ from decimal import Decimal
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from registrasion import models as rego from registrasion import models as rego
from cart_controller_helper import TestingCartController from controller_helpers import TestingCartController
from registrasion.controllers.invoice import InvoiceController from controller_helpers import TestingInvoiceController
from test_cart import RegistrationCartTestCase from test_cart import RegistrationCartTestCase
@ -20,7 +20,7 @@ class InvoiceTestCase(RegistrationCartTestCase):
# Should be able to create an invoice after the product is added # Should be able to create an invoice after the product is added
current_cart.add_to_cart(self.PROD_1, 1) current_cart.add_to_cart(self.PROD_1, 1)
invoice_1 = InvoiceController.for_cart(current_cart.cart) invoice_1 = TestingInvoiceController.for_cart(current_cart.cart)
# That invoice should have a single line item # That invoice should have a single line item
line_items = rego.LineItem.objects.filter(invoice=invoice_1.invoice) line_items = rego.LineItem.objects.filter(invoice=invoice_1.invoice)
self.assertEqual(1, len(line_items)) self.assertEqual(1, len(line_items))
@ -29,14 +29,14 @@ class InvoiceTestCase(RegistrationCartTestCase):
# Adding item to cart should produce a new invoice # Adding item to cart should produce a new invoice
current_cart.add_to_cart(self.PROD_2, 1) current_cart.add_to_cart(self.PROD_2, 1)
invoice_2 = InvoiceController.for_cart(current_cart.cart) invoice_2 = TestingInvoiceController.for_cart(current_cart.cart)
self.assertNotEqual(invoice_1.invoice, invoice_2.invoice) self.assertNotEqual(invoice_1.invoice, invoice_2.invoice)
# The old invoice should automatically be voided # The old invoice should automatically be voided
invoice_1_new = rego.Invoice.objects.get(pk=invoice_1.invoice.id) invoice_1_new = rego.Invoice.objects.get(pk=invoice_1.invoice.id)
invoice_2_new = rego.Invoice.objects.get(pk=invoice_2.invoice.id) invoice_2_new = rego.Invoice.objects.get(pk=invoice_2.invoice.id)
self.assertTrue(invoice_1_new.void) self.assertTrue(invoice_1_new.is_void)
self.assertFalse(invoice_2_new.void) self.assertFalse(invoice_2_new.is_void)
# Invoice should have two line items # Invoice should have two line items
line_items = rego.LineItem.objects.filter(invoice=invoice_2.invoice) line_items = rego.LineItem.objects.filter(invoice=invoice_2.invoice)
@ -58,17 +58,17 @@ class InvoiceTestCase(RegistrationCartTestCase):
# Now try to invoice the first user # Now try to invoice the first user
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):
InvoiceController.for_cart(current_cart.cart) TestingInvoiceController.for_cart(current_cart.cart)
def test_paying_invoice_makes_new_cart(self): def test_paying_invoice_makes_new_cart(self):
current_cart = TestingCartController.for_user(self.USER_1) current_cart = TestingCartController.for_user(self.USER_1)
current_cart.add_to_cart(self.PROD_1, 1) current_cart.add_to_cart(self.PROD_1, 1)
invoice = InvoiceController.for_cart(current_cart.cart) invoice = TestingInvoiceController.for_cart(current_cart.cart)
invoice.pay("A payment!", invoice.invoice.value) invoice.pay("A payment!", invoice.invoice.value)
# This payment is for the correct amount invoice should be paid. # 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 # Cart should not be active
self.assertFalse(invoice.invoice.cart.active) self.assertFalse(invoice.invoice.cart.active)
@ -99,7 +99,7 @@ class InvoiceTestCase(RegistrationCartTestCase):
# Should be able to create an invoice after the product is added # Should be able to create an invoice after the product is added
current_cart.add_to_cart(self.PROD_1, 1) current_cart.add_to_cart(self.PROD_1, 1)
invoice_1 = InvoiceController.for_cart(current_cart.cart) invoice_1 = TestingInvoiceController.for_cart(current_cart.cart)
# That invoice should have two line items # That invoice should have two line items
line_items = rego.LineItem.objects.filter(invoice=invoice_1.invoice) line_items = rego.LineItem.objects.filter(invoice=invoice_1.invoice)
@ -131,43 +131,43 @@ class InvoiceTestCase(RegistrationCartTestCase):
# Should be able to create an invoice after the product is added # Should be able to create an invoice after the product is added
current_cart.add_to_cart(self.PROD_1, 1) current_cart.add_to_cart(self.PROD_1, 1)
invoice_1 = InvoiceController.for_cart(current_cart.cart) 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): def test_invoice_voids_self_if_cart_is_invalid(self):
current_cart = TestingCartController.for_user(self.USER_1) current_cart = TestingCartController.for_user(self.USER_1)
# Should be able to create an invoice after the product is added # Should be able to create an invoice after the product is added
current_cart.add_to_cart(self.PROD_1, 1) current_cart.add_to_cart(self.PROD_1, 1)
invoice_1 = InvoiceController.for_cart(current_cart.cart) 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 # Adding item to cart should produce a new invoice
current_cart.add_to_cart(self.PROD_2, 1) current_cart.add_to_cart(self.PROD_2, 1)
invoice_2 = InvoiceController.for_cart(current_cart.cart) invoice_2 = TestingInvoiceController.for_cart(current_cart.cart)
self.assertNotEqual(invoice_1.invoice, invoice_2.invoice) self.assertNotEqual(invoice_1.invoice, invoice_2.invoice)
# Viewing invoice_1's invoice should show it as void # Viewing invoice_1's invoice should show it as void
invoice_1_new = InvoiceController(invoice_1.invoice) 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 # Viewing invoice_2's invoice should *not* show it as void
invoice_2_new = InvoiceController(invoice_2.invoice) 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): def test_voiding_invoice_creates_new_invoice(self):
current_cart = TestingCartController.for_user(self.USER_1) current_cart = TestingCartController.for_user(self.USER_1)
# Should be able to create an invoice after the product is added # Should be able to create an invoice after the product is added
current_cart.add_to_cart(self.PROD_1, 1) current_cart.add_to_cart(self.PROD_1, 1)
invoice_1 = InvoiceController.for_cart(current_cart.cart) 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_1.void()
invoice_2 = InvoiceController.for_cart(current_cart.cart) invoice_2 = TestingInvoiceController.for_cart(current_cart.cart)
self.assertNotEqual(invoice_1.invoice, invoice_2.invoice) self.assertNotEqual(invoice_1.invoice, invoice_2.invoice)
def test_cannot_pay_void_invoice(self): def test_cannot_pay_void_invoice(self):
@ -175,19 +175,19 @@ class InvoiceTestCase(RegistrationCartTestCase):
# Should be able to create an invoice after the product is added # Should be able to create an invoice after the product is added
current_cart.add_to_cart(self.PROD_1, 1) current_cart.add_to_cart(self.PROD_1, 1)
invoice_1 = InvoiceController.for_cart(current_cart.cart) invoice_1 = TestingInvoiceController.for_cart(current_cart.cart)
invoice_1.void() invoice_1.void()
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):
invoice_1.pay("Reference", invoice_1.invoice.value) invoice_1.validate_allowed_to_pay()
def test_cannot_void_paid_invoice(self): def test_cannot_void_paid_invoice(self):
current_cart = TestingCartController.for_user(self.USER_1) current_cart = TestingCartController.for_user(self.USER_1)
# Should be able to create an invoice after the product is added # Should be able to create an invoice after the product is added
current_cart.add_to_cart(self.PROD_1, 1) current_cart.add_to_cart(self.PROD_1, 1)
invoice_1 = InvoiceController.for_cart(current_cart.cart) invoice_1 = TestingInvoiceController.for_cart(current_cart.cart)
invoice_1.pay("Reference", invoice_1.invoice.value) invoice_1.pay("Reference", invoice_1.invoice.value)
@ -197,4 +197,24 @@ class InvoiceTestCase(RegistrationCartTestCase):
def test_cannot_generate_blank_invoice(self): def test_cannot_generate_blank_invoice(self):
current_cart = TestingCartController.for_user(self.USER_1) current_cart = TestingCartController.for_user(self.USER_1)
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):
InvoiceController.for_cart(current_cart.cart) invoice_1 = TestingInvoiceController.for_cart(current_cart.cart)
def test_cannot_pay_implicitly_void_invoice(self):
cart = TestingCartController.for_user(self.USER_1)
cart.add_to_cart(self.PROD_1, 1)
invoice = TestingInvoiceController.for_cart(self.reget(cart.cart))
# Implicitly void the invoice
cart.add_to_cart(self.PROD_1, 1)
with self.assertRaises(ValidationError):
invoice.validate_allowed_to_pay()
# TODO: test partially paid invoice cannot be void until payments
# are refunded
# TODO: test overpaid invoice results in credit note
# TODO: test credit note generation more generally

View file

@ -1,7 +1,7 @@
import pytz import pytz
from cart_controller_helper import TestingCartController from controller_helpers import TestingCartController
from registrasion.controllers.invoice import InvoiceController from controller_helpers import TestingInvoiceController
from test_cart import RegistrationCartTestCase from test_cart import RegistrationCartTestCase
@ -15,14 +15,16 @@ class RefundTestCase(RegistrationCartTestCase):
# Should be able to create an invoice after the product is added # Should be able to create an invoice after the product is added
current_cart.add_to_cart(self.PROD_1, 1) current_cart.add_to_cart(self.PROD_1, 1)
invoice = InvoiceController.for_cart(current_cart.cart) invoice = TestingInvoiceController.for_cart(current_cart.cart)
invoice.pay("A Payment!", invoice.invoice.value) invoice.pay("A Payment!", invoice.invoice.value)
self.assertFalse(invoice.invoice.void) self.assertFalse(invoice.invoice.is_void)
self.assertTrue(invoice.invoice.paid) self.assertTrue(invoice.invoice.is_paid)
self.assertFalse(invoice.invoice.is_refunded)
self.assertFalse(invoice.invoice.cart.released) self.assertFalse(invoice.invoice.cart.released)
invoice.refund("A Refund!", invoice.invoice.value) invoice.refund("A Refund!", invoice.invoice.value)
self.assertTrue(invoice.invoice.void) self.assertFalse(invoice.invoice.is_void)
self.assertFalse(invoice.invoice.paid) self.assertFalse(invoice.invoice.is_paid)
self.assertTrue(invoice.invoice.is_refunded)
self.assertTrue(invoice.invoice.cart.released) self.assertTrue(invoice.invoice.cart.released)

View file

@ -6,8 +6,8 @@ from django.core.exceptions import ValidationError
from django.db import IntegrityError from django.db import IntegrityError
from registrasion import models as rego from registrasion import models as rego
from cart_controller_helper import TestingCartController from controller_helpers import TestingCartController
from registrasion.controllers.invoice import InvoiceController from controller_helpers import TestingInvoiceController
from test_cart import RegistrationCartTestCase from test_cart import RegistrationCartTestCase
@ -140,8 +140,8 @@ class VoucherTestCases(RegistrationCartTestCase):
current_cart.apply_voucher(voucher.code) current_cart.apply_voucher(voucher.code)
current_cart.add_to_cart(self.PROD_1, 1) current_cart.add_to_cart(self.PROD_1, 1)
inv = InvoiceController.for_cart(current_cart.cart) inv = TestingInvoiceController.for_cart(current_cart.cart)
if not inv.invoice.paid: if not inv.invoice.is_paid:
inv.pay("Hello!", inv.invoice.value) inv.pay("Hello!", inv.invoice.value)
current_cart = TestingCartController.for_user(self.USER_1) current_cart = TestingCartController.for_user(self.USER_1)

View file

@ -1,3 +1,5 @@
import views
from django.conf.urls import url, patterns from django.conf.urls import url, patterns
urlpatterns = patterns( urlpatterns = patterns(
@ -5,7 +7,11 @@ urlpatterns = patterns(
url(r"^category/([0-9]+)$", "product_category", name="product_category"), url(r"^category/([0-9]+)$", "product_category", name="product_category"),
url(r"^checkout$", "checkout", name="checkout"), url(r"^checkout$", "checkout", name="checkout"),
url(r"^invoice/([0-9]+)$", "invoice", name="invoice"), url(r"^invoice/([0-9]+)$", "invoice", name="invoice"),
url(r"^invoice/([0-9]+)/pay$", "pay_invoice", name="pay_invoice"), url(r"^invoice/([0-9]+)/([A-Z0-9]+)$", views.invoice, name="invoice"),
url(r"^invoice/([0-9]+)/manual_payment$",
views.manual_payment, name="manual_payment"),
url(r"^invoice_access/([A-Z0-9]+)$", views.invoice_access,
name="invoice_access"),
url(r"^profile$", "edit_profile", name="attendee_edit"), url(r"^profile$", "edit_profile", name="attendee_edit"),
url(r"^register$", "guided_registration", name="guided_registration"), url(r"^register$", "guided_registration", name="guided_registration"),
url(r"^register/([0-9]+)$", "guided_registration", url(r"^register/([0-9]+)$", "guided_registration",

15
registrasion/util.py Normal file
View file

@ -0,0 +1,15 @@
import string
from django.utils.crypto import get_random_string
def generate_access_code():
''' Generates an access code for users' payments as well as their
fulfilment code for check-in.
The access code will 4 characters long, which allows for 1,500,625
unique codes, which really should be enough for anyone. '''
length = 4
# all upper-case letters + digits 1-9 (no 0 vs O confusion)
chars = string.uppercase + string.digits[1:]
# 4 chars => 35 ** 4 = 1500625 (should be enough for anyone)
return get_random_string(length=length, allowed_chars=chars)

View file

@ -16,6 +16,7 @@ from django.contrib import messages
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.http import Http404 from django.http import Http404
from django.shortcuts import get_object_or_404
from django.shortcuts import redirect from django.shortcuts import redirect
from django.shortcuts import render from django.shortcuts import render
@ -423,18 +424,41 @@ def checkout_errors(request, errors):
return render(request, "registrasion/checkout_errors.html", data) return render(request, "registrasion/checkout_errors.html", data)
@login_required def invoice_access(request, access_code):
def invoice(request, invoice_id): ''' Redirects to the first unpaid invoice for the attendee that matches
''' Displays an invoice for a given invoice id. ''' the given access code, if any. '''
invoices = rego.Invoice.objects.filter(
user__attendee__access_code=access_code,
status=rego.Invoice.STATUS_UNPAID,
).order_by("issue_time")
if not invoices:
raise Http404()
invoice = invoices[0]
return redirect("invoice", invoice.id, access_code)
def invoice(request, invoice_id, access_code=None):
''' Displays an invoice for a given invoice id.
This view is not authenticated, but it will only allow access to either:
the user the invoice belongs to; staff; or a request made with the correct
access code.
'''
invoice_id = int(invoice_id) invoice_id = int(invoice_id)
inv = rego.Invoice.objects.get(pk=invoice_id) inv = rego.Invoice.objects.get(pk=invoice_id)
if request.user != inv.cart.user and not request.user.is_staff:
raise Http404()
current_invoice = InvoiceController(inv) current_invoice = InvoiceController(inv)
if not current_invoice.can_view(
user=request.user,
access_code=access_code,
):
raise Http404()
data = { data = {
"invoice": current_invoice.invoice, "invoice": current_invoice.invoice,
} }
@ -443,15 +467,32 @@ def invoice(request, invoice_id):
@login_required @login_required
def pay_invoice(request, invoice_id): def manual_payment(request, invoice_id):
''' Marks the invoice with the given invoice id as paid. ''' Allows staff to make manual payments or refunds on an invoice.'''
WORK IN PROGRESS FUNCTION. Must be replaced with real payment workflow.
FORM_PREFIX = "manual_payment"
if not request.user.is_staff:
raise Http404()
'''
invoice_id = int(invoice_id) invoice_id = int(invoice_id)
inv = rego.Invoice.objects.get(pk=invoice_id) inv = get_object_or_404(rego.Invoice, pk=invoice_id)
current_invoice = InvoiceController(inv) current_invoice = InvoiceController(inv)
if not current_invoice.invoice.paid and not current_invoice.invoice.void:
current_invoice.pay("Demo invoice payment", inv.value)
return redirect("invoice", current_invoice.invoice.id) form = forms.ManualPaymentForm(
request.POST or None,
prefix=FORM_PREFIX,
)
if request.POST and form.is_valid():
form.instance.invoice = inv
form.save()
current_invoice.update_status()
form = forms.ManualPaymentForm(prefix=FORM_PREFIX)
data = {
"invoice": inv,
"form": form,
}
return render(request, "registrasion/manual_payment.html", data)