diff --git a/registrasion/controllers/invoice.py b/registrasion/controllers/invoice.py index 264049c8..de401d31 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,26 @@ 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) + + # 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( 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=recipient, ) product_items = rego.ProductItem.objects.filter(cart=cart) @@ -84,6 +106,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 +116,162 @@ 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 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): - ''' 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/forms.py b/registrasion/forms.py index 47dacd3d..68c69d36 100644 --- a/registrasion/forms.py +++ b/registrasion/forms.py @@ -3,6 +3,13 @@ import models as rego 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 # and the fields added as needs be. 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/migrations/0014_attendee_access_code.py b/registrasion/migrations/0014_attendee_access_code.py new file mode 100644 index 00000000..a579f47a --- /dev/null +++ b/registrasion/migrations/0014_attendee_access_code.py @@ -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), + ), + ] diff --git a/registrasion/migrations/0015_auto_20160408_0220.py b/registrasion/migrations/0015_auto_20160408_0220.py new file mode 100644 index 00000000..acdde45f --- /dev/null +++ b/registrasion/migrations/0015_auto_20160408_0220.py @@ -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), + ), + ] diff --git a/registrasion/migrations/0016_auto_20160408_0234.py b/registrasion/migrations/0016_auto_20160408_0234.py new file mode 100644 index 00000000..94beb184 --- /dev/null +++ b/registrasion/migrations/0016_auto_20160408_0234.py @@ -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), + ), + ] diff --git a/registrasion/models.py b/registrasion/models.py index 941ebef5..2719dd0e 100644 --- a/registrasion/models.py +++ b/registrasion/models.py @@ -1,8 +1,11 @@ from __future__ import unicode_literals +import util + 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,16 +29,25 @@ 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) + + def save(self, *a, **k): + 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) # Badge/profile is linked + access_code = models.CharField( + max_length=6, + unique=True, + db_index=True, + ) completed_registration = models.BooleanField(default=False) 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. ''' 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 +558,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 +578,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 +626,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/cart_controller_helper.py b/registrasion/tests/controller_helpers.py similarity index 63% rename from registrasion/tests/cart_controller_helper.py rename to registrasion/tests/controller_helpers.py index 9e4191f0..476351dd 100644 --- a/registrasion/tests/cart_controller_helper.py +++ b/registrasion/tests/controller_helpers.py @@ -1,7 +1,9 @@ from registrasion.controllers.cart import CartController +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): @@ -28,3 +30,21 @@ class TestingCartController(CartController): def next_cart(self): self.cart.active = False 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() diff --git a/registrasion/tests/test_cart.py b/registrasion/tests/test_cart.py index d47d659e..f8a82c21 100644 --- a/registrasion/tests/test_cart.py +++ b/registrasion/tests/test_cart.py @@ -10,7 +10,7 @@ from django.test import TestCase from registrasion import models as rego from registrasion.controllers.product import ProductController -from cart_controller_helper import TestingCartController +from controller_helpers import TestingCartController from patch_datetime import SetTimeMixin UTC = pytz.timezone('UTC') @@ -23,6 +23,9 @@ class RegistrationCartTestCase(SetTimeMixin, TestCase): @classmethod def setUpTestData(cls): + + super(RegistrationCartTestCase, cls).setUpTestData() + cls.USER_1 = User.objects.create_user( username='testuser', email='test@example.com', @@ -33,6 +36,15 @@ class RegistrationCartTestCase(SetTimeMixin, TestCase): email='test2@example.com', 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.categories = [] @@ -136,6 +148,10 @@ class RegistrationCartTestCase(SetTimeMixin, TestCase): voucher.save() return voucher + @classmethod + def reget(cls, object): + return type(object).objects.get(id=object.id) + class BasicCartTests(RegistrationCartTestCase): diff --git a/registrasion/tests/test_ceilings.py b/registrasion/tests/test_ceilings.py index f8481fea..c5664481 100644 --- a/registrasion/tests/test_ceilings.py +++ b/registrasion/tests/test_ceilings.py @@ -3,7 +3,7 @@ import pytz from django.core.exceptions import ValidationError -from cart_controller_helper import TestingCartController +from controller_helpers import TestingCartController from test_cart import RegistrationCartTestCase from registrasion import models as rego diff --git a/registrasion/tests/test_discount.py b/registrasion/tests/test_discount.py index fb35330e..e9105378 100644 --- a/registrasion/tests/test_discount.py +++ b/registrasion/tests/test_discount.py @@ -4,7 +4,8 @@ from decimal import Decimal from registrasion import models as rego 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 diff --git a/registrasion/tests/test_enabling_condition.py b/registrasion/tests/test_enabling_condition.py index 5d0e410c..d977cc5c 100644 --- a/registrasion/tests/test_enabling_condition.py +++ b/registrasion/tests/test_enabling_condition.py @@ -4,7 +4,7 @@ from django.core.exceptions import ValidationError from registrasion import models as rego from registrasion.controllers.category import CategoryController -from cart_controller_helper import TestingCartController +from controller_helpers import TestingCartController from registrasion.controllers.product import ProductController from test_cart import RegistrationCartTestCase diff --git a/registrasion/tests/test_invoice.py b/registrasion/tests/test_invoice.py index 19332392..b121bfa1 100644 --- a/registrasion/tests/test_invoice.py +++ b/registrasion/tests/test_invoice.py @@ -5,8 +5,8 @@ from decimal import Decimal from django.core.exceptions import ValidationError from registrasion import models as rego -from cart_controller_helper import TestingCartController -from registrasion.controllers.invoice import InvoiceController +from controller_helpers import TestingCartController +from controller_helpers import TestingInvoiceController from test_cart import RegistrationCartTestCase @@ -20,7 +20,7 @@ class InvoiceTestCase(RegistrationCartTestCase): # Should be able to create an invoice after the product is added 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 line_items = rego.LineItem.objects.filter(invoice=invoice_1.invoice) self.assertEqual(1, len(line_items)) @@ -29,14 +29,14 @@ class InvoiceTestCase(RegistrationCartTestCase): # Adding item to cart should produce a new invoice 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) # 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) @@ -58,17 +58,17 @@ class InvoiceTestCase(RegistrationCartTestCase): # Now try to invoice the first user with self.assertRaises(ValidationError): - InvoiceController.for_cart(current_cart.cart) + TestingInvoiceController.for_cart(current_cart.cart) def test_paying_invoice_makes_new_cart(self): current_cart = TestingCartController.for_user(self.USER_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) # 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) @@ -99,7 +99,7 @@ class InvoiceTestCase(RegistrationCartTestCase): # Should be able to create an invoice after the product is added 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 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 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): current_cart = TestingCartController.for_user(self.USER_1) # Should be able to create an invoice after the product is added 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 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) # Viewing invoice_1's invoice should show it as void - invoice_1_new = InvoiceController(invoice_1.invoice) - self.assertTrue(invoice_1_new.invoice.void) + invoice_1_new = TestingInvoiceController(invoice_1.invoice) + self.assertTrue(invoice_1_new.invoice.is_void) # Viewing invoice_2's invoice should *not* show it as void - invoice_2_new = InvoiceController(invoice_2.invoice) - self.assertFalse(invoice_2_new.invoice.void) + invoice_2_new = TestingInvoiceController(invoice_2.invoice) + self.assertFalse(invoice_2_new.invoice.is_void) def test_voiding_invoice_creates_new_invoice(self): current_cart = TestingCartController.for_user(self.USER_1) # Should be able to create an invoice after the product is added 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_2 = InvoiceController.for_cart(current_cart.cart) + invoice_2 = TestingInvoiceController.for_cart(current_cart.cart) self.assertNotEqual(invoice_1.invoice, invoice_2.invoice) 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 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() 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): current_cart = TestingCartController.for_user(self.USER_1) # Should be able to create an invoice after the product is added 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) @@ -197,4 +197,24 @@ class InvoiceTestCase(RegistrationCartTestCase): def test_cannot_generate_blank_invoice(self): current_cart = TestingCartController.for_user(self.USER_1) 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 diff --git a/registrasion/tests/test_refund.py b/registrasion/tests/test_refund.py index bde25929..35457749 100644 --- a/registrasion/tests/test_refund.py +++ b/registrasion/tests/test_refund.py @@ -1,7 +1,7 @@ import pytz -from cart_controller_helper import TestingCartController -from registrasion.controllers.invoice import InvoiceController +from controller_helpers import TestingCartController +from controller_helpers import TestingInvoiceController from test_cart import RegistrationCartTestCase @@ -15,14 +15,16 @@ class RefundTestCase(RegistrationCartTestCase): # Should be able to create an invoice after the product is added 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) - 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 a8bc6b00..be598206 100644 --- a/registrasion/tests/test_voucher.py +++ b/registrasion/tests/test_voucher.py @@ -6,8 +6,8 @@ from django.core.exceptions import ValidationError from django.db import IntegrityError from registrasion import models as rego -from cart_controller_helper import TestingCartController -from registrasion.controllers.invoice import InvoiceController +from controller_helpers import TestingCartController +from controller_helpers import TestingInvoiceController from test_cart import RegistrationCartTestCase @@ -140,8 +140,8 @@ class VoucherTestCases(RegistrationCartTestCase): current_cart.apply_voucher(voucher.code) current_cart.add_to_cart(self.PROD_1, 1) - inv = InvoiceController.for_cart(current_cart.cart) - if not inv.invoice.paid: + inv = TestingInvoiceController.for_cart(current_cart.cart) + if not inv.invoice.is_paid: inv.pay("Hello!", inv.invoice.value) current_cart = TestingCartController.for_user(self.USER_1) diff --git a/registrasion/urls.py b/registrasion/urls.py index 0620dafb..0949e4b4 100644 --- a/registrasion/urls.py +++ b/registrasion/urls.py @@ -1,3 +1,5 @@ +import views + from django.conf.urls import url, patterns urlpatterns = patterns( @@ -5,7 +7,11 @@ urlpatterns = patterns( url(r"^category/([0-9]+)$", "product_category", name="product_category"), url(r"^checkout$", "checkout", name="checkout"), 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"^register$", "guided_registration", name="guided_registration"), url(r"^register/([0-9]+)$", "guided_registration", diff --git a/registrasion/util.py b/registrasion/util.py new file mode 100644 index 00000000..fb97d1d5 --- /dev/null +++ b/registrasion/util.py @@ -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) diff --git a/registrasion/views.py b/registrasion/views.py index b3f9212d..251f0fba 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -16,6 +16,7 @@ from django.contrib import messages from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ValidationError from django.http import Http404 +from django.shortcuts import get_object_or_404 from django.shortcuts import redirect from django.shortcuts import render @@ -423,18 +424,41 @@ def checkout_errors(request, errors): return render(request, "registrasion/checkout_errors.html", data) -@login_required -def invoice(request, invoice_id): - ''' Displays an invoice for a given invoice id. ''' +def invoice_access(request, access_code): + ''' Redirects to the first unpaid invoice for the attendee that matches + 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) 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) + if not current_invoice.can_view( + user=request.user, + access_code=access_code, + ): + raise Http404() + data = { "invoice": current_invoice.invoice, } @@ -443,15 +467,32 @@ def invoice(request, invoice_id): @login_required -def pay_invoice(request, invoice_id): - ''' Marks the invoice with the given invoice id as paid. - WORK IN PROGRESS FUNCTION. Must be replaced with real payment workflow. +def manual_payment(request, invoice_id): + ''' Allows staff to make manual payments or refunds on an invoice.''' + + FORM_PREFIX = "manual_payment" + + if not request.user.is_staff: + raise Http404() - ''' 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) - 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)