Changeset - 4a50d699365b
[Not reviewed]
0 5 0
Christopher Neugebauer - 8 years ago 2016-09-15 23:35:12
chrisjrn@gmail.com
Moves total_payments() to Invoice model; adds balance_due()
5 files changed with 43 insertions and 40 deletions:
0 comments (0 inline, 0 general)
registrasion/controllers/invoice.py
Show inline comments
...
 
@@ -176,270 +176,263 @@ class InvoiceController(ForId, object):
 
        # Get the invoice recipient
 
        profile = people.AttendeeProfileBase.objects.get_subclass(
 
            id=user.attendee.attendeeprofilebase.id,
 
        )
 
        recipient = profile.invoice_recipient()
 

	
 
        invoice_value = sum(item.quantity * item.price for item in line_items)
 

	
 
        invoice = commerce.Invoice.objects.create(
 
            user=user,
 
            cart=cart,
 
            cart_revision=cart.revision if cart else None,
 
            status=commerce.Invoice.STATUS_UNPAID,
 
            value=invoice_value,
 
            issue_time=issued,
 
            due_time=due,
 
            recipient=recipient,
 
        )
 

	
 
        # Associate the line items with the invoice
 
        for line_item in line_items:
 
            line_item.invoice = invoice
 

	
 
        commerce.LineItem.objects.bulk_create(line_items)
 

	
 
        cls._apply_credit_notes(invoice)
 
        cls.email_on_invoice_creation(invoice)
 

	
 
        return invoice
 

	
 
    @classmethod
 
    def _apply_credit_notes(cls, invoice):
 
        ''' Applies the user's credit notes to the given invoice on creation.
 
        '''
 

	
 
        # We only automatically apply credit notes if this is the *only*
 
        # unpaid invoice for this user.
 
        invoices = commerce.Invoice.objects.filter(
 
            user=invoice.user,
 
            status=commerce.Invoice.STATUS_UNPAID,
 
        )
 
        if invoices.count() > 1:
 
            return
 

	
 
        notes = commerce.CreditNote.unclaimed().filter(
 
            invoice__user=invoice.user
 
        )
 
        for note in notes:
 
            try:
 
                CreditNoteController(note).apply_to_invoice(invoice)
 
            except ValidationError:
 
                # ValidationError will get raised once we're overpaying.
 
                break
 

	
 
        invoice.refresh_from_db()
 

	
 
    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()
 
        total_paid = self.invoice.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:
 
            if self.invoice.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:
 
        if self.invoice.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()
 
        amount = self.invoice.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")
registrasion/models/commerce.py
Show inline comments
 
from . import conditions
 
from . import inventory
 

	
 
from django.contrib.auth.models import User
 
from django.core.exceptions import ValidationError
 
from django.db import models
 
from django.db.models import F, Q
 
from django.db.models import F, Q, Sum
 
from django.utils import timezone
 
from django.utils.encoding import python_2_unicode_compatible
 
from django.utils.translation import ugettext_lazy as _
 
from model_utils.managers import InheritanceManager
 

	
 

	
 
# Commerce Models
 

	
 
@python_2_unicode_compatible
 
class Cart(models.Model):
 
    ''' Represents a set of product items that have been purchased, or are
 
    pending purchase. '''
 

	
 
    class Meta:
 
        app_label = "registrasion"
 
        index_together = [
 
            ("status", "time_last_updated"),
 
            ("status", "user"),
 
        ]
 

	
 
    def __str__(self):
 
        return "%d rev #%d" % (self.id, self.revision)
 

	
 
    STATUS_ACTIVE = 1
 
    STATUS_PAID = 2
 
    STATUS_RELEASED = 3
 

	
 
    STATUS_TYPES = [
 
        (STATUS_ACTIVE, _("Active")),
 
        (STATUS_PAID, _("Paid")),
 
        (STATUS_RELEASED, _("Released")),
 
    ]
 

	
 
    user = models.ForeignKey(User)
 
    # ProductItems (foreign key)
 
    vouchers = models.ManyToManyField(inventory.Voucher, blank=True)
 
    time_last_updated = models.DateTimeField(
 
        db_index=True,
 
    )
 
    reservation_duration = models.DurationField()
 
    revision = models.PositiveIntegerField(default=1)
 
    status = models.IntegerField(
 
        choices=STATUS_TYPES,
 
        db_index=True,
 
        default=STATUS_ACTIVE,
 
    )
 

	
 
    @classmethod
 
    def reserved_carts(cls):
 
        ''' Gets all carts that are 'reserved' '''
 
        return Cart.objects.filter(
 
            (Q(status=Cart.STATUS_ACTIVE) &
 
                Q(time_last_updated__gt=(
 
                    timezone.now()-F('reservation_duration')
 
                                        ))) |
 
            Q(status=Cart.STATUS_PAID)
 
        )
 

	
 

	
 
@python_2_unicode_compatible
 
class ProductItem(models.Model):
 
    ''' Represents a product-quantity pair in a Cart. '''
 

	
 
    class Meta:
 
        app_label = "registrasion"
 
        ordering = ("product", )
 

	
 
    def __str__(self):
 
        return "product: %s * %d in Cart: %s" % (
 
            self.product, self.quantity, self.cart)
 

	
 
    cart = models.ForeignKey(Cart)
 
    product = models.ForeignKey(inventory.Product)
 
    quantity = models.PositiveIntegerField(db_index=True)
 

	
 

	
 
@python_2_unicode_compatible
 
class DiscountItem(models.Model):
 
    ''' Represents a discount-product-quantity relation in a Cart. '''
 

	
 
    class Meta:
 
        app_label = "registrasion"
 
        ordering = ("product", )
 

	
 
    def __str__(self):
 
        return "%s: %s * %d in Cart: %s" % (
 
            self.discount, self.product, self.quantity, self.cart)
 

	
 
    cart = models.ForeignKey(Cart)
 
    product = models.ForeignKey(inventory.Product)
 
    discount = models.ForeignKey(conditions.DiscountBase)
 
    quantity = models.PositiveIntegerField()
 

	
 

	
 
@python_2_unicode_compatible
 
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.
 

	
 
    Attributes:
 

	
 
        user (User): The owner of this invoice.
 

	
 
        cart (commerce.Cart): The cart that was used to generate this invoice.
 

	
 
        cart_revision (int): The value of ``cart.revision`` at the time of this
 
            invoice's creation. If a change is made to the underlying cart,
 
            this invoice is automatically void -- this change is detected
 
            when ``cart.revision != cart_revision``.
 

	
 
        status (int): One of ``STATUS_UNPAID``, ``STATUS_PAID``,
 
            ``STATUS_REFUNDED``, OR ``STATUS_VOID``. Call
 
            ``get_status_display`` for a human-readable representation.
 

	
 
        recipient (str): A rendered representation of the invoice's recipient.
 

	
 
        issue_time (datetime): When the invoice was issued.
 

	
 
        due_time (datetime): When the invoice is due.
 

	
 
        value (Decimal): The total value of the line items attached to the
 
            invoice.
 

	
 
        lineitem_set (Queryset[LineItem]): The set of line items that comprise
 
            this invoice.
 

	
 
        paymentbase_set(Queryset[PaymentBase]): The set of PaymentBase objects
 
            that have been applied to this invoice.
 

	
 
    '''
 

	
 
    class Meta:
 
        app_label = "registrasion"
 

	
 
    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
 

	
 
    def clean(self):
 
        if self.cart is not None and self.cart_revision is None:
 
            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
 

	
 
    def total_payments(self):
 
        ''' Returns the total amount paid towards this invoice. '''
 

	
 
        payments = PaymentBase.objects.filter(invoice=self)
 
        total_paid = payments.aggregate(Sum("amount"))["amount__sum"] or 0
 
        return total_paid
 

	
 
    def balance_due(self):
 
        ''' Returns the total balance remaining towards this invoice. '''
 
        return self.value - self.total_payments()
 

	
 
    # Invoice Number
 
    user = models.ForeignKey(User)
 
    cart = models.ForeignKey(Cart, null=True)
 
    cart_revision = models.IntegerField(
 
        null=True,
 
        db_index=True,
 
    )
 
    # Line Items (foreign key)
 
    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)
 

	
 

	
 
@python_2_unicode_compatible
 
class LineItem(models.Model):
 
    ''' Line items for an invoice. These are denormalised from the ProductItems
 
    and DiscountItems that belong to a cart (for consistency), but also allow
 
    for arbitrary line items when required.
 

	
 
    Attributes:
 

	
 
        invoice (commerce.Invoice): The invoice to which this LineItem is
 
            attached.
 

	
 
        description (str): A human-readable description of the line item.
 

	
 
        quantity (int): The quantity of items represented by this line.
 

	
 
        price (Decimal): The per-unit price for this line item.
 

	
 
        product (Optional[inventory.Product]): The product that this LineItem
 
            applies to. This allows you to do reports on sales and applied
 
            discounts to individual products.
 

	
 
    '''
 

	
 
    class Meta:
 
        app_label = "registrasion"
 
        ordering = ("id", )
 

	
 
    def __str__(self):
 
        return "Line: %s * %d @ %s" % (
 
            self.description, self.quantity, self.price)
 

	
 
    @property
 
    def total_price(self):
 
        ''' price * quantity '''
 
        return self.price * self.quantity
 

	
 
    invoice = models.ForeignKey(Invoice)
 
    description = models.CharField(max_length=255)
 
    quantity = models.PositiveIntegerField()
 
    price = models.DecimalField(max_digits=8, decimal_places=2)
 
    product = models.ForeignKey(inventory.Product, null=True, blank=True)
 

	
 

	
 
@python_2_unicode_compatible
 
class PaymentBase(models.Model):
 
    ''' The base payment type for invoices. Payment apps should subclass this
 
    class to handle implementation-specific issues.
 

	
 
    Attributes:
 
        invoice (inventory.Invoice): The invoice that this payment applies to.
 

	
 
        time (datetime): The time that this payment was generated. Note that
 
            this will default to the current time when the model is created.
 

	
 
        reference (str): A human-readable reference for the payment, this will
 
            be displayed alongside the invoice.
 

	
 
        amount (Decimal): The amount the payment is for.
 

	
 
    '''
 

	
 
    class Meta:
 
        ordering = ("time", )
 

	
 
    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=255)
 
    amount = models.DecimalField(max_digits=8, decimal_places=2)
 

	
 

	
 
class ManualPayment(PaymentBase):
 
    ''' Payments that are manually entered by staff. '''
 

	
 
    class Meta:
 
        app_label = "registrasion"
 

	
 
    entered_by = models.ForeignKey(User)
 

	
 

	
 
class CreditNote(PaymentBase):
 
    ''' Credit notes represent money accounted for in the system that do not
 
    belong to specific invoices. They may be paid into other invoices, or
 
    cashed out as refunds.
 

	
 
    Each CreditNote may either be used to pay towards another Invoice in the
 
    system (by attaching a CreditNoteApplication), or may be marked as
 
    refunded (by attaching a CreditNoteRefund).'''
 

	
 
    class Meta:
 
        app_label = "registrasion"
 

	
 
    @classmethod
 
    def unclaimed(cls):
 
        return cls.objects.filter(
 
            creditnoteapplication=None,
 
            creditnoterefund=None,
 
        )
 

	
 
    @property
 
    def status(self):
 
        if self.is_unclaimed:
 
            return "Unclaimed"
 

	
 
        if hasattr(self, 'creditnoteapplication'):
 
            destination = self.creditnoteapplication.invoice.id
 
            return "Applied to invoice %d" % destination
 

	
 
        elif hasattr(self, 'creditnoterefund'):
 
            reference = self.creditnoterefund.reference
 
            print reference
 
            return "Refunded with reference: %s" % reference
 

	
 
        raise ValueError("This should never happen.")
 

	
 
    @property
 
    def is_unclaimed(self):
 
        return not (
 
            hasattr(self, 'creditnoterefund') or
 
            hasattr(self, 'creditnoteapplication')
 
        )
 

	
 
    @property
 
    def value(self):
 
        ''' Returns the value of the credit note. Because CreditNotes are
 
        implemented as PaymentBase objects internally, the amount is a
 
        negative payment against an invoice. '''
 
        return -self.amount
registrasion/templatetags/registrasion_tags.py
Show inline comments
 
from registrasion.models import commerce
 
from registrasion.controllers.category import CategoryController
 
from registrasion.controllers.item import ItemController
 

	
 
from django import template
 
from django.db.models import Sum
 

	
 
register = template.Library()
 

	
 

	
 
@register.assignment_tag(takes_context=True)
 
def available_categories(context):
 
    ''' Gets all of the currently available products.
 

	
 
    Returns:
 
        [models.inventory.Category, ...]: A list of all of the categories that
 
            have Products that the current user can reserve.
 

	
 
    '''
 
    return CategoryController.available_categories(context.request.user)
 

	
 

	
 
@register.assignment_tag(takes_context=True)
 
def missing_categories(context):
 
    ''' Adds the categories that the user does not currently have. '''
 
    user = context.request.user
 
    categories_available = set(CategoryController.available_categories(user))
 
    items = ItemController(user).items_pending_or_purchased()
 

	
 
    categories_held = set()
 

	
 
    for product, quantity in items:
 
        categories_held.add(product.category)
 

	
 
    return categories_available - categories_held
 

	
 

	
 
@register.assignment_tag(takes_context=True)
 
def available_credit(context):
 
    ''' Calculates the sum of unclaimed credit from this user's credit notes.
 

	
 
    Returns:
 
        Decimal: the sum of the values of unclaimed credit notes for the
 
            current user.
 

	
 
    '''
 

	
 
    notes = commerce.CreditNote.unclaimed().filter(
 
        invoice__user=context.request.user,
 
    )
 
    ret = notes.values("amount").aggregate(Sum("amount"))["amount__sum"] or 0
 
    return 0 - ret
 

	
 

	
 
@register.assignment_tag(takes_context=True)
 
def invoices(context):
 
    '''
 

	
 
    Returns:
 
        [models.commerce.Invoice, ...]: All of the current user's invoices. '''
 
    return commerce.Invoice.objects.filter(user=context.request.user)
 

	
 

	
 
@register.assignment_tag(takes_context=True)
 
def items_pending(context):
 
    ''' Gets all of the items that the user from this context has reserved.'''
 
    return ItemController(context.request.user).items_pending()
 

	
 

	
 
@register.assignment_tag(takes_context=True)
 
def items_purchased(context, category=None):
 
    ''' Returns the items purchased for this user. '''
 

	
 
    return ItemController(context.request.user).items_purchased(
 
        category=category
 
    )
 

	
 

	
 
@register.filter
 
def multiply(value, arg):
 
    ''' Multiplies value by arg.
 

	
 
    This is useful when displaying invoices, as it lets you multiply the
 
    quantity by the unit value.
 

	
 
    Arguments:
 

	
 
        value (number)
 

	
 
        arg (number)
 

	
 
    Returns:
 
        number: value * arg
 

	
 
    '''
 

	
 
    return value * arg
registrasion/tests/test_credit_note.py
Show inline comments
 
import datetime
 
import pytz
 

	
 
from decimal import Decimal
 
from django.core.exceptions import ValidationError
 

	
 
from registrasion.models import commerce
 
from registrasion.models import conditions
 
from registrasion.models import inventory
 
from controller_helpers import TestingCartController
 
from controller_helpers import TestingCreditNoteController
 
from controller_helpers import TestingInvoiceController
 
from test_helpers import TestHelperMixin
 

	
 
from test_cart import RegistrationCartTestCase
 

	
 
UTC = pytz.timezone('UTC')
 

	
 
HOURS = datetime.timedelta(hours=1)
 

	
 

	
 
class CreditNoteTestCase(TestHelperMixin, RegistrationCartTestCase):
 

	
 
    def test_overpaid_invoice_results_in_credit_note(self):
 
        invoice = self._invoice_containing_prod_1(1)
 

	
 
        # Invoice is overpaid by 1 unit
 
        to_pay = invoice.invoice.value + 1
 
        invoice.pay("Reference", to_pay)
 

	
 
        # The total paid should be equal to the value of the invoice only
 
        self.assertEqual(invoice.invoice.value, invoice.total_payments())
 
        self.assertEqual(
 
            invoice.invoice.value, invoice.invoice.total_payments()
 
        )
 
        self.assertTrue(invoice.invoice.is_paid)
 

	
 
        # There should be a credit note generated out of the invoice.
 
        credit_notes = commerce.CreditNote.objects.filter(
 
            invoice=invoice.invoice,
 
        )
 
        self.assertEqual(1, credit_notes.count())
 
        self.assertEqual(to_pay - invoice.invoice.value, credit_notes[0].value)
 

	
 
    def test_full_paid_invoice_does_not_generate_credit_note(self):
 
        invoice = self._invoice_containing_prod_1(1)
 

	
 
        # Invoice is paid evenly
 
        invoice.pay("Reference", invoice.invoice.value)
 

	
 
        # The total paid should be equal to the value of the invoice only
 
        self.assertEqual(invoice.invoice.value, invoice.total_payments())
 
        self.assertEqual(
 
            invoice.invoice.value, invoice.invoice.total_payments()
 
        )
 
        self.assertTrue(invoice.invoice.is_paid)
 

	
 
        # There should be no credit notes
 
        credit_notes = commerce.CreditNote.objects.filter(
 
            invoice=invoice.invoice,
 
        )
 
        self.assertEqual(0, credit_notes.count())
 

	
 
    def test_refund_partially_paid_invoice_generates_correct_credit_note(self):
 
        invoice = self._invoice_containing_prod_1(1)
 

	
 
        # Invoice is underpaid by 1 unit
 
        to_pay = invoice.invoice.value - 1
 
        invoice.pay("Reference", to_pay)
 
        invoice.refund()
 

	
 
        # The total paid should be zero
 
        self.assertEqual(0, invoice.total_payments())
 
        self.assertEqual(0, invoice.invoice.total_payments())
 
        self.assertTrue(invoice.invoice.is_void)
 

	
 
        # There should be a credit note generated out of the invoice.
 
        credit_notes = commerce.CreditNote.objects.filter(
 
            invoice=invoice.invoice,
 
        )
 
        self.assertEqual(1, credit_notes.count())
 
        self.assertEqual(to_pay, credit_notes[0].value)
 

	
 
    def test_refund_fully_paid_invoice_generates_correct_credit_note(self):
 
        invoice = self._invoice_containing_prod_1(1)
 

	
 
        to_pay = invoice.invoice.value
 
        invoice.pay("Reference", to_pay)
 
        self.assertTrue(invoice.invoice.is_paid)
 

	
 
        invoice.refund()
 

	
 
        # The total paid should be zero
 
        self.assertEqual(0, invoice.total_payments())
 
        self.assertEqual(0, invoice.invoice.total_payments())
 
        self.assertTrue(invoice.invoice.is_refunded)
 

	
 
        # There should be a credit note generated out of the invoice.
 
        credit_notes = commerce.CreditNote.objects.filter(
 
            invoice=invoice.invoice,
 
        )
 
        self.assertEqual(1, credit_notes.count())
 
        self.assertEqual(to_pay, credit_notes[0].value)
 

	
 
    def test_apply_credit_note_pays_invoice(self):
 

	
 
        # Create a manual invoice (stops credit notes from being auto-applied)
 
        self._manual_invoice(1)
 

	
 
        # Begin the test
 

	
 
        invoice = self._invoice_containing_prod_1(1)
 

	
 
        to_pay = invoice.invoice.value
 
        invoice.pay("Reference", to_pay)
 
        self.assertTrue(invoice.invoice.is_paid)
 

	
 
        invoice.refund()
 

	
 
        # There should be one credit note generated out of the invoice.
 
        cn = self._credit_note_for_invoice(invoice.invoice)
 

	
 
        # That credit note should be in the unclaimed pile
 
        self.assertEquals(1, commerce.CreditNote.unclaimed().count())
 

	
 
        # Create a new (identical) cart with invoice
 
        cart = TestingCartController.for_user(self.USER_1)
 
        cart.add_to_cart(self.PROD_1, 1)
 

	
 
        invoice2 = TestingInvoiceController.for_cart(self.reget(cart.cart))
 

	
 
        cn.apply_to_invoice(invoice2.invoice)
 
        self.assertTrue(invoice2.invoice.is_paid)
 

	
 
        # That invoice should not show up as unclaimed any more
 
        self.assertEquals(0, commerce.CreditNote.unclaimed().count())
 

	
 
    def test_apply_credit_note_generates_new_credit_note_if_overpaying(self):
 

	
 
        # Create and refund an invoice, generating a credit note.
 
        invoice = self._invoice_containing_prod_1(2)
 

	
 
        invoice.pay("Reference", invoice.invoice.value)
 
        self.assertTrue(invoice.invoice.is_paid)
 

	
 
        invoice.refund()
 

	
 
        # There should be one credit note generated out of the invoice.
 
        cn = self._credit_note_for_invoice(invoice.invoice)
 

	
 
        self.assertEquals(1, commerce.CreditNote.unclaimed().count())
 

	
 
        # Create a new invoice for a cart of half value of inv 1
 
        invoice2 = self._invoice_containing_prod_1(1)
 
        # Credit note is automatically applied by generating the new invoice
 
        self.assertTrue(invoice2.invoice.is_paid)
 

	
 
        # We generated a new credit note, and spent the old one,
 
        # unclaimed should still be 1.
 
        self.assertEquals(1, commerce.CreditNote.unclaimed().count())
 

	
 
        credit_note2 = commerce.CreditNote.objects.get(
 
            invoice=invoice2.invoice,
 
        )
 

	
 
        # The new credit note should be the residual of the cost of cart 1
 
        # minus the cost of cart 2.
 
        self.assertEquals(
 
            invoice.invoice.value - invoice2.invoice.value,
 
            credit_note2.value,
 
        )
 

	
 
    def test_cannot_apply_credit_note_on_invalid_invoices(self):
 

	
 
        # Disable auto-application of invoices.
 
        self._manual_invoice(1)
 

	
 
        # And now start the actual test.
 

	
 
        invoice = self._invoice_containing_prod_1(1)
 

	
 
        to_pay = invoice.invoice.value
 
        invoice.pay("Reference", to_pay)
 
        self.assertTrue(invoice.invoice.is_paid)
 

	
 
        invoice.refund()
 

	
 
        # There should be one credit note generated out of the invoice.
 
        cn = self._credit_note_for_invoice(invoice.invoice)
 

	
 
        # Create a new cart with invoice, pay it
...
 
@@ -274,194 +278,194 @@ class CreditNoteTestCase(TestHelperMixin, RegistrationCartTestCase):
 
        invoice.pay("Paying the first time.", val)
 
        invoice.refund()
 

	
 
        cnval = val - 1
 
        invoice.pay("Paying into the void.", cnval, pre_validate=False)
 

	
 
        notes = commerce.CreditNote.objects.filter(invoice=invoice.invoice)
 
        notes = sorted(notes, key=lambda note: note.value)
 

	
 
        self.assertEqual(cnval, notes[0].value)
 
        self.assertEqual(val, notes[1].value)
 

	
 
    def test_money_into_paid_invoice_generates_credit_note(self):
 
        invoice = self._invoice_containing_prod_1(1)
 

	
 
        val = invoice.invoice.value
 

	
 
        invoice.pay("Paying the first time.", val)
 

	
 
        invoice.pay("Paying into the void.", val, pre_validate=False)
 
        cn = self._credit_note_for_invoice(invoice.invoice)
 
        self.assertEqual(val, cn.credit_note.value)
 

	
 
    def test_invoice_with_credit_note_applied_is_refunded(self):
 
        ''' Invoices with partial payments should void when cart is updated.
 

	
 
        Test for issue #64 -- applying a credit note to an invoice
 
        means that invoice cannot be voided, and new invoices cannot be
 
        created. '''
 

	
 
        invoice = self._invoice_containing_prod_1(1)
 

	
 
        # Now get a credit note
 
        invoice.pay("Lol", invoice.invoice.value)
 
        invoice.refund()
 
        cn = self._credit_note_for_invoice(invoice.invoice)
 

	
 
        # Create a cart of higher value than the credit note
 
        cart = TestingCartController.for_user(self.USER_1)
 
        cart.add_to_cart(self.PROD_1, 2)
 

	
 
        # Create a current invoice
 
        # This will automatically apply `cn` to the invoice
 
        invoice = TestingInvoiceController.for_cart(cart.cart)
 

	
 
        # Adding to cart will mean that the old invoice for this cart
 
        # will be invalidated. A new invoice should be generated.
 
        cart.add_to_cart(self.PROD_1, 1)
 
        invoice = TestingInvoiceController.for_id(invoice.invoice.id)
 
        invoice2 = TestingInvoiceController.for_cart(cart.cart)
 
        cn2 = self._credit_note_for_invoice(invoice.invoice)
 

	
 
        invoice._refresh()
 

	
 
        # The first invoice should be refunded
 
        self.assertEquals(
 
            commerce.Invoice.STATUS_VOID,
 
            invoice.invoice.status,
 
        )
 

	
 
        # Both credit notes should be for the same amount
 
        self.assertEquals(
 
            cn.credit_note.value,
 
            cn2.credit_note.value,
 
        )
 

	
 
    def test_creating_invoice_automatically_applies_credit_note(self):
 
        ''' Single credit note is automatically applied to new invoices. '''
 

	
 
        invoice = self._invoice_containing_prod_1(1)
 
        invoice.pay("boop", invoice.invoice.value)
 
        invoice.refund()
 

	
 
        # Generate a new invoice to the same value as first invoice
 
        # Should be paid, because we're applying credit notes automatically
 
        invoice2 = self._invoice_containing_prod_1(1)
 
        self.assertTrue(invoice2.invoice.is_paid)
 

	
 
    def _generate_multiple_credit_notes(self):
 
        invoice1 = self._manual_invoice(11)
 
        invoice2 = self._manual_invoice(11)
 
        invoice1.pay("Pay", invoice1.invoice.value)
 
        invoice1.refund()
 
        invoice2.pay("Pay", invoice2.invoice.value)
 
        invoice2.refund()
 
        return invoice1.invoice.value + invoice2.invoice.value
 

	
 
    def test_mutiple_credit_notes_are_applied_when_generating_invoice_1(self):
 
        ''' Tests (1) that multiple credit notes are applied to new invoice.
 

	
 
        Sum of credit note values will be *LESS* than the new invoice.
 
        '''
 

	
 
        notes_value = self._generate_multiple_credit_notes()
 
        invoice = self._manual_invoice(notes_value + 1)
 

	
 
        self.assertEqual(notes_value, invoice.total_payments())
 
        self.assertEqual(notes_value, invoice.invoice.total_payments())
 
        self.assertTrue(invoice.invoice.is_unpaid)
 

	
 
        user_unclaimed = commerce.CreditNote.unclaimed()
 
        user_unclaimed = user_unclaimed.filter(invoice__user=self.USER_1)
 
        self.assertEqual(0, user_unclaimed.count())
 

	
 
    def test_mutiple_credit_notes_are_applied_when_generating_invoice_2(self):
 
        ''' Tests (2) that multiple credit notes are applied to new invoice.
 

	
 
        Sum of credit note values will be *GREATER* than the new invoice.
 
        '''
 

	
 
        notes_value = self._generate_multiple_credit_notes()
 
        invoice = self._manual_invoice(notes_value - 1)
 

	
 

	
 
        self.assertEqual(notes_value - 1, invoice.total_payments())
 
        self.assertEqual(notes_value - 1, invoice.invoice.total_payments())
 
        self.assertTrue(invoice.invoice.is_paid)
 

	
 
        user_unclaimed = commerce.CreditNote.unclaimed().filter(
 
            invoice__user=self.USER_1
 
        )
 
        self.assertEqual(1, user_unclaimed.count())
 

	
 
        excess = self._credit_note_for_invoice(invoice.invoice)
 
        self.assertEqual(excess.credit_note.value, 1)
 

	
 
    def test_credit_notes_are_left_over_if_not_all_are_needed(self):
 
        ''' Tests that excess credit notes are untouched if they're not needed
 
        '''
 

	
 
        notes_value = self._generate_multiple_credit_notes()
 
        notes_old = commerce.CreditNote.unclaimed().filter(
 
            invoice__user=self.USER_1
 
        )
 

	
 
        # Create a manual invoice whose value is smaller than any of the
 
        # credit notes we created
 
        invoice = self._manual_invoice(1)
 
        notes_new = commerce.CreditNote.unclaimed().filter(
 
            invoice__user=self.USER_1
 
        )
 

	
 
        # Item is True if the note was't consumed when generating invoice.
 
        note_was_unused = [(i in notes_old) for i in notes_new]
 
        self.assertIn(True, note_was_unused)
 

	
 
    def test_credit_notes_are_not_applied_if_user_has_multiple_invoices(self):
 

	
 
        # Have an invoice pending with no credit notes; no payment will be made
 
        invoice1 = self._invoice_containing_prod_1(1)
 
        # Create some credit notes.
 
        self._generate_multiple_credit_notes()
 

	
 
        invoice = self._manual_invoice(2)
 

	
 
        # Because there's already an invoice open for this user
 
        # The credit notes are not automatically applied.
 
        self.assertEqual(0, invoice.total_payments())
 
        self.assertEqual(0, invoice.invoice.total_payments())
 
        self.assertTrue(invoice.invoice.is_unpaid)
 

	
 
    def test_credit_notes_are_applied_even_if_some_notes_are_claimed(self):
 

	
 
        for i in xrange(10):
 
            # Generate credit note
 
            invoice1 = self._manual_invoice(1)
 
            invoice1.pay("Pay", invoice1.invoice.value)
 
            invoice1.refund()
 

	
 
            # Generate invoice that should be automatically paid
 
            invoice2 = self._manual_invoice(1)
 
            self.assertTrue(invoice2.invoice.is_paid)
 

	
 
    def test_cancellation_fee_is_applied(self):
 

	
 
        invoice1 = self._manual_invoice(1)
 
        invoice1.pay("Pay", invoice1.invoice.value)
 
        invoice1.refund()
 

	
 
        percentage = 15
 

	
 
        cn = self._credit_note_for_invoice(invoice1.invoice)
 
        canc = cn.cancellation_fee(15)
 

	
 
        # Cancellation fee exceeds the amount for the invoice.
 
        self.assertTrue(canc.invoice.is_paid)
 

	
 
        # Cancellation fee is equal to 15% of credit note's value
 
        self.assertEqual(
 
            canc.invoice.value,
 
            cn.credit_note.value * percentage / 100
 
        )
 

	
 
    def test_cancellation_fee_is_applied_when_another_invoice_is_unpaid(self):
 

	
 
        extra_invoice = self._manual_invoice(23)
 
        self.test_cancellation_fee_is_applied()
registrasion/tests/test_invoice.py
Show inline comments
...
 
@@ -4,192 +4,203 @@ import pytz
 
from decimal import Decimal
 
from django.core.exceptions import ValidationError
 

	
 
from registrasion.models import commerce
 
from registrasion.models import conditions
 
from registrasion.models import inventory
 
from controller_helpers import TestingCartController
 
from controller_helpers import TestingCreditNoteController
 
from controller_helpers import TestingInvoiceController
 
from test_helpers import TestHelperMixin
 

	
 
from test_cart import RegistrationCartTestCase
 

	
 
UTC = pytz.timezone('UTC')
 

	
 

	
 
class InvoiceTestCase(TestHelperMixin, RegistrationCartTestCase):
 

	
 
    def test_create_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 = TestingInvoiceController.for_cart(current_cart.cart)
 
        # That invoice should have a single line item
 
        line_items = commerce.LineItem.objects.filter(
 
            invoice=invoice_1.invoice,
 
        )
 
        self.assertEqual(1, len(line_items))
 
        # That invoice should have a value equal to cost of PROD_1
 
        self.assertEqual(self.PROD_1.price, invoice_1.invoice.value)
 

	
 
        # Adding item to cart should produce a new invoice
 
        current_cart.add_to_cart(self.PROD_2, 1)
 
        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 = commerce.Invoice.objects.get(pk=invoice_1.invoice.id)
 
        invoice_2_new = commerce.Invoice.objects.get(pk=invoice_2.invoice.id)
 
        self.assertTrue(invoice_1_new.is_void)
 
        self.assertFalse(invoice_2_new.is_void)
 

	
 
        # Invoice should have two line items
 
        line_items = commerce.LineItem.objects.filter(
 
            invoice=invoice_2.invoice,
 
        )
 
        self.assertEqual(2, len(line_items))
 
        # Invoice should have a value equal to cost of PROD_1 and PROD_2
 
        self.assertEqual(
 
            self.PROD_1.price + self.PROD_2.price,
 
            invoice_2.invoice.value)
 

	
 
    def test_invoice_controller_for_id_works(self):
 
        invoice = self._invoice_containing_prod_1(1)
 

	
 
        id_ = invoice.invoice.id
 

	
 
        invoice1 = TestingInvoiceController.for_id(id_)
 
        invoice2 = TestingInvoiceController.for_id(str(id_))
 

	
 
        self.assertEqual(invoice.invoice, invoice1.invoice)
 
        self.assertEqual(invoice.invoice, invoice2.invoice)
 

	
 
    def test_create_invoice_fails_if_cart_invalid(self):
 
        self.make_ceiling("Limit ceiling", limit=1)
 
        self.set_time(datetime.datetime(2015, 01, 01, tzinfo=UTC))
 
        current_cart = TestingCartController.for_user(self.USER_1)
 
        current_cart.add_to_cart(self.PROD_1, 1)
 

	
 
        self.add_timedelta(self.RESERVATION * 2)
 
        cart_2 = TestingCartController.for_user(self.USER_2)
 
        cart_2.add_to_cart(self.PROD_1, 1)
 

	
 
        # Now try to invoice the first user
 
        with self.assertRaises(ValidationError):
 
            TestingInvoiceController.for_cart(current_cart.cart)
 

	
 
    def test_paying_invoice_makes_new_cart(self):
 
        invoice = self._invoice_containing_prod_1(1)
 

	
 
        invoice.pay("A payment!", invoice.invoice.value)
 

	
 
        # This payment is for the correct amount invoice should be paid.
 
        self.assertTrue(invoice.invoice.is_paid)
 

	
 
        # Cart should not be active
 
        self.assertNotEqual(
 
            commerce.Cart.STATUS_ACTIVE,
 
            invoice.invoice.cart.status,
 
        )
 

	
 
        # Asking for a cart should generate a new one
 
        new_cart = TestingCartController.for_user(self.USER_1)
 
        self.assertNotEqual(invoice.invoice.cart, new_cart.cart)
 

	
 
    def test_total_payments_balance_due(self):
 
        invoice = self._invoice_containing_prod_1(2)
 
        for i in xrange(0, invoice.invoice.value):
 
            self.assertTrue(
 
                i + 1, invoice.invoice.total_payments()
 
            )
 
            self.assertTrue(
 
                invoice.invoice.value - i, invoice.invoice.balance_due()
 
            )
 
            invoice.pay("Pay 1", 1)
 

	
 
    def test_invoice_includes_discounts(self):
 
        voucher = inventory.Voucher.objects.create(
 
            recipient="Voucher recipient",
 
            code="VOUCHER",
 
            limit=1
 
        )
 
        discount = conditions.VoucherDiscount.objects.create(
 
            description="VOUCHER RECIPIENT",
 
            voucher=voucher,
 
        )
 
        conditions.DiscountForProduct.objects.create(
 
            discount=discount,
 
            product=self.PROD_1,
 
            percentage=Decimal(50),
 
            quantity=1
 
        )
 

	
 
        current_cart = TestingCartController.for_user(self.USER_1)
 
        current_cart.apply_voucher(voucher.code)
 

	
 
        # Should be able to create an invoice after the product is added
 
        current_cart.add_to_cart(self.PROD_1, 1)
 
        invoice_1 = TestingInvoiceController.for_cart(current_cart.cart)
 

	
 
        # That invoice should have two line items
 
        line_items = commerce.LineItem.objects.filter(
 
            invoice=invoice_1.invoice,
 
        )
 
        self.assertEqual(2, len(line_items))
 
        # That invoice should have a value equal to 50% of the cost of PROD_1
 
        self.assertEqual(
 
            self.PROD_1.price * Decimal("0.5"),
 
            invoice_1.invoice.value)
 

	
 
    def test_zero_value_invoice_is_automatically_paid(self):
 
        voucher = inventory.Voucher.objects.create(
 
            recipient="Voucher recipient",
 
            code="VOUCHER",
 
            limit=1
 
        )
 
        discount = conditions.VoucherDiscount.objects.create(
 
            description="VOUCHER RECIPIENT",
 
            voucher=voucher,
 
        )
 
        conditions.DiscountForProduct.objects.create(
 
            discount=discount,
 
            product=self.PROD_1,
 
            percentage=Decimal(100),
 
            quantity=1
 
        )
 

	
 
        current_cart = TestingCartController.for_user(self.USER_1)
 
        current_cart.apply_voucher(voucher.code)
 

	
 
        # Should be able to create an invoice after the product is added
 
        current_cart.add_to_cart(self.PROD_1, 1)
 
        invoice_1 = TestingInvoiceController.for_cart(current_cart.cart)
 

	
 
        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 = TestingInvoiceController.for_cart(current_cart.cart)
 

	
 
        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 = 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 = 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 = TestingInvoiceController(invoice_2.invoice)
 
        self.assertFalse(invoice_2_new.invoice.is_void)
 

	
 
    def test_voiding_invoice_creates_new_invoice(self):
 
        invoice_1 = self._invoice_containing_prod_1(1)
 

	
 
        self.assertFalse(invoice_1.invoice.is_void)
 
        invoice_1.void()
 

	
 
        invoice_2 = TestingInvoiceController.for_cart(invoice_1.invoice.cart)
 
        self.assertNotEqual(invoice_1.invoice, invoice_2.invoice)
 

	
 
    def test_cannot_pay_void_invoice(self):
 
        invoice_1 = self._invoice_containing_prod_1(1)
 

	
 
        invoice_1.void()
 

	
0 comments (0 inline, 0 general)