Merge branch 'invoices_and_payments'
This commit is contained in:
commit
a12460e351
18 changed files with 578 additions and 141 deletions
|
@ -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()
|
|
||||||
|
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
@ -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),
|
||||||
|
),
|
||||||
|
]
|
21
registrasion/migrations/0014_attendee_access_code.py
Normal file
21
registrasion/migrations/0014_attendee_access_code.py
Normal 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),
|
||||||
|
),
|
||||||
|
]
|
20
registrasion/migrations/0015_auto_20160408_0220.py
Normal file
20
registrasion/migrations/0015_auto_20160408_0220.py
Normal 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),
|
||||||
|
),
|
||||||
|
]
|
20
registrasion/migrations/0016_auto_20160408_0234.py
Normal file
20
registrasion/migrations/0016_auto_20160408_0234.py
Normal 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),
|
||||||
|
),
|
||||||
|
]
|
|
@ -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
|
||||||
|
|
|
@ -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()
|
|
@ -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):
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
15
registrasion/util.py
Normal 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)
|
|
@ -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)
|
||||||
|
|
Loading…
Reference in a new issue