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()