Files @ ae8f39381f4a
Branch filter:

Location: symposion_app/registrasion/controllers/invoice.py

Christopher Neugebauer
Flake8 fixes
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 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):
        ''' 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()