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.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()
|
||||
|
|
|
@ -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.
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
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
|
||||
|
|
|
@ -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()
|
|
@ -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):
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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",
|
||||
|
|
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 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)
|
||||
|
|
Loading…
Reference in a new issue