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.contrib.mail import send_email from registrasion.models import commerce from registrasion.models import conditions from registrasion.models import people from cart import CartController from credit_note import CreditNoteController from for_id import ForId class InvoiceController(ForId, object): __MODEL__ = commerce.Invoice 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.''' cart.refresh_from_db() try: invoice = commerce.Invoice.objects.exclude( status=commerce.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.update_old_invoices(cart) invoice = cls._generate(cart) return cls(invoice) @classmethod def update_old_invoices(cls, cart): invoices = commerce.Invoice.objects.filter(cart=cart).all() for invoice in invoices: cls(invoice).update_status() @classmethod def resolve_discount_value(cls, item): try: condition = conditions.DiscountForProduct.objects.get( discount=item.discount, product=item.product ) except ObjectDoesNotExist: condition = conditions.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. ''' cart.refresh_from_db() 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 = people.AttendeeProfileBase.objects.get_subclass( id=cart.user.attendee.attendeeprofilebase.id, ) recipient = profile.invoice_recipient() invoice = commerce.Invoice.objects.create( user=cart.user, cart=cart, cart_revision=cart.revision, status=commerce.Invoice.STATUS_UNPAID, value=Decimal(), issue_time=issued, due_time=due, recipient=recipient, ) product_items = commerce.ProductItem.objects.filter(cart=cart) product_items = product_items.select_related( "product", "product__category", ) if len(product_items) == 0: raise ValidationError("Your cart is empty.") product_items = product_items.order_by( "product__category__order", "product__order" ) discount_items = commerce.DiscountItem.objects.filter(cart=cart) discount_items = discount_items.select_related( "discount", "product", "product__category", ) line_items = [] def format_product(product): return "%s - %s" % (product.category.name, product.name) def format_discount(discount, product): description = discount.description return "%s (%s)" % (description, format_product(product)) invoice_value = Decimal() for item in product_items: product = item.product line_item = commerce.LineItem( invoice=invoice, description=format_product(product), quantity=item.quantity, price=product.price, product=product, ) line_items.append(line_item) invoice_value += line_item.quantity * line_item.price for item in discount_items: line_item = commerce.LineItem( invoice=invoice, description=format_discount(item.discount, item.product), quantity=item.quantity, price=cls.resolve_discount_value(item) * -1, product=item.product, ) line_items.append(line_item) invoice_value += line_item.quantity * line_item.price commerce.LineItem.objects.bulk_create(line_items) invoice.value = invoice_value invoice.save() cls.email_on_invoice_creation(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 = commerce.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 = commerce.PaymentBase.objects.filter( invoice=self.invoice, ).count() remainder = self.invoice.value - total_paid if old_status == commerce.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 == commerce.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 == commerce.Invoice.STATUS_REFUNDED: # Should not ever change from here pass elif old_status == commerce.Invoice.STATUS_VOID: # Should not ever change from here pass # Generate credit notes from residual payments residual = 0 if self.invoice.is_paid: if remainder < 0: residual = 0 - remainder elif self.invoice.is_void or self.invoice.is_refunded: residual = total_paid if residual != 0: CreditNoteController.generate_from_invoice(self.invoice, residual) self.email_on_invoice_change( self.invoice, old_status, self.invoice.status, ) def _mark_paid(self): ''' Marks the invoice as paid, and updates the attached cart if necessary. ''' cart = self.invoice.cart if cart: cart.status = commerce.Cart.STATUS_PAID cart.save() self.invoice.status = commerce.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.status = commerce.Cart.STATUS_RELEASED cart.save() self.invoice.status = commerce.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 = commerce.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. ''' self._refresh() 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(): if self.total_payments() > 0: # Free up the payments made to this invoice self.refund() else: self.void() def void(self): ''' Voids the invoice if it is valid to do so. ''' if self.total_payments() > 0: raise ValidationError("Invoices with payments must be refunded.") elif self.invoice.is_refunded: raise ValidationError("Refunded invoices may not be voided.") self._mark_void() @transaction.atomic def refund(self): ''' Refunds the invoice by generating a CreditNote for the value of all of the payments against the cart. The invoice is marked as refunded, and the underlying cart is marked as released. ''' if self.invoice.is_void: raise ValidationError("Void invoices cannot be refunded") # Raises a credit note fot the value of the invoice. amount = self.total_payments() if amount == 0: self.void() return CreditNoteController.generate_from_invoice(self.invoice, amount) self.update_status() @classmethod def email(cls, invoice, kind): ''' Sends out an e-mail notifying the user about something to do with that invoice. ''' context = { "invoice": invoice, } send_email([invoice.user.email], kind, context=context) @classmethod def email_on_invoice_creation(cls, invoice): ''' Sends out an e-mail notifying the user that an invoice has been created. ''' cls.email(invoice, "invoice_created") @classmethod def email_on_invoice_change(cls, invoice, old_status, new_status): ''' Sends out all of the necessary notifications that the status of the invoice has changed to: - Invoice is now paid - Invoice is now refunded ''' # The statuses that we don't care about. silent_status = [ commerce.Invoice.STATUS_VOID, commerce.Invoice.STATUS_UNPAID, ] if old_status == new_status: return if False and new_status in silent_status: pass cls.email(invoice, "invoice_updated")