260 lines
8.6 KiB
Python
260 lines
8.6 KiB
Python
from decimal import Decimal
|
|
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
|
|
|
|
from cart import CartController
|
|
|
|
|
|
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
|
|
def for_cart(cls, cart):
|
|
''' Returns an invoice object for a given cart at its current revision.
|
|
If such an invoice does not exist, the cart is validated, and if valid,
|
|
an invoice is generated.'''
|
|
|
|
try:
|
|
invoice = rego.Invoice.objects.exclude(
|
|
status=rego.Invoice.STATUS_VOID,
|
|
).get(
|
|
cart=cart,
|
|
cart_revision=cart.revision,
|
|
)
|
|
except ObjectDoesNotExist:
|
|
cart_controller = CartController(cart)
|
|
cart_controller.validate_cart() # Raises ValidationError on fail.
|
|
|
|
cls.void_all_invoices(cart)
|
|
invoice = cls._generate(cart)
|
|
|
|
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):
|
|
try:
|
|
condition = rego.DiscountForProduct.objects.get(
|
|
discount=item.discount,
|
|
product=item.product
|
|
)
|
|
except ObjectDoesNotExist:
|
|
condition = rego.DiscountForCategory.objects.get(
|
|
discount=item.discount,
|
|
category=item.product.category
|
|
)
|
|
if condition.percentage is not None:
|
|
value = item.product.price * (condition.percentage / 100)
|
|
else:
|
|
value = condition.price
|
|
return value
|
|
|
|
@classmethod
|
|
@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,
|
|
status=rego.Invoice.STATUS_UNPAID,
|
|
value=Decimal(),
|
|
issue_time=issued,
|
|
due_time=due,
|
|
recipient=recipient,
|
|
)
|
|
|
|
product_items = rego.ProductItem.objects.filter(cart=cart)
|
|
|
|
if len(product_items) == 0:
|
|
raise ValidationError("Your cart is empty.")
|
|
|
|
product_items = product_items.order_by(
|
|
"product__category__order", "product__order"
|
|
)
|
|
discount_items = rego.DiscountItem.objects.filter(cart=cart)
|
|
invoice_value = Decimal()
|
|
for item in product_items:
|
|
product = item.product
|
|
line_item = rego.LineItem.objects.create(
|
|
invoice=invoice,
|
|
description="%s - %s" % (product.category.name, product.name),
|
|
quantity=item.quantity,
|
|
price=product.price,
|
|
product=product,
|
|
)
|
|
invoice_value += line_item.quantity * line_item.price
|
|
|
|
for item in discount_items:
|
|
line_item = rego.LineItem.objects.create(
|
|
invoice=invoice,
|
|
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
|
|
|
|
invoice.save()
|
|
|
|
return invoice
|
|
|
|
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):
|
|
''' 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.status == rego.Invoice.STATUS_PAID:
|
|
raise ValidationError("Paid invoices cannot be voided, "
|
|
"only refunded.")
|
|
self._mark_void()
|
|
|
|
@transaction.atomic
|
|
def refund(self, reference, amount):
|
|
''' 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.is_void:
|
|
raise ValidationError("Void invoices cannot be refunded")
|
|
|
|
# Adds a payment
|
|
# TODO: replace by creating a credit note instead
|
|
rego.ManualPayment.objects.create(
|
|
invoice=self.invoice,
|
|
reference=reference,
|
|
amount=0 - amount,
|
|
)
|
|
|
|
self.update_status()
|