Changeset - fd9980efc578
[Not reviewed]
0 2 0
Christopher Neugebauer - 8 years ago 2016-09-15 01:41:50
chrisjrn@gmail.com
Makes sure we only apply unclaimed credit notes when auto-applying credit notes.
2 files changed with 15 insertions and 1 deletions:
0 comments (0 inline, 0 general)
registrasion/controllers/invoice.py
Show inline comments
...
 
@@ -28,385 +28,387 @@ class InvoiceController(ForId, object):
 
    @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_from_cart(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 manual_invoice(cls, user, due_delta, description_price_pairs):
 
        ''' Generates an invoice for arbitrary items, not held in a user's
 
        cart.
 

	
 
        Arguments:
 
            user (User): The user the invoice is being generated for.
 
            due_delta (datetime.timedelta): The length until the invoice is
 
                due.
 
            description_price_pairs ([(str, long or Decimal), ...]): A list of
 
                pairs. Each pair consists of the description for each line item
 
                and the price for that line item. The price will be cast to
 
                Decimal.
 

	
 
        Returns:
 
            an Invoice.
 
        '''
 

	
 
        line_items = []
 
        for description, price in description_price_pairs:
 
            line_item = commerce.LineItem(
 
                description=description,
 
                quantity=1,
 
                price=Decimal(price),
 
                product=None,
 
            )
 
            line_items.append(line_item)
 

	
 
        min_due_time = timezone.now() + due_delta
 
        return cls._generate(user, None, min_due_time, line_items)
 

	
 
    @classmethod
 
    @transaction.atomic
 
    def _generate_from_cart(cls, cart):
 
        ''' Generates an invoice for the given cart. '''
 

	
 
        cart.refresh_from_db()
 

	
 
        # Generate the line items from the cart.
 

	
 
        product_items = commerce.ProductItem.objects.filter(cart=cart)
 
        product_items = product_items.select_related(
 
            "product",
 
            "product__category",
 
        )
 
        product_items = product_items.order_by(
 
            "product__category__order", "product__order"
 
        )
 

	
 
        if len(product_items) == 0:
 
            raise ValidationError("Your cart is empty.")
 

	
 
        discount_items = commerce.DiscountItem.objects.filter(cart=cart)
 
        discount_items = discount_items.select_related(
 
            "discount",
 
            "product",
 
            "product__category",
 
        )
 

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

	
 
        line_items = []
 

	
 
        for item in product_items:
 
            product = item.product
 
            line_item = commerce.LineItem(
 
                description=format_product(product),
 
                quantity=item.quantity,
 
                price=product.price,
 
                product=product,
 
            )
 
            line_items.append(line_item)
 
        for item in discount_items:
 
            line_item = commerce.LineItem(
 
                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)
 

	
 
        # Generate the invoice
 

	
 
        min_due_time = cart.reservation_duration + cart.time_last_updated
 

	
 
        return cls._generate(cart.user, cart, min_due_time, line_items)
 

	
 
    @classmethod
 
    @transaction.atomic
 
    def _generate(cls, user, cart, min_due_time, line_items):
 

	
 
        # Never generate a due time that is before the issue time
 
        issued = timezone.now()
 
        due = max(issued, min_due_time)
 

	
 
        # 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.objects.filter(invoice__user=invoice.user)
 
        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()
 
        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,
 
        }
 

	
registrasion/tests/test_credit_note.py
Show inline comments
...
 
@@ -239,192 +239,204 @@ class CreditNoteTestCase(TestHelperMixin, RegistrationCartTestCase):
 

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

	
 
        cn = self._credit_note_for_invoice(invoice.invoice)
 

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

	
 
        invoice_2 = TestingInvoiceController.for_cart(self.reget(cart.cart))
 
        with self.assertRaises(ValidationError):
 
            # Creating `invoice_2` will automatically apply `cn`.
 
            cn.apply_to_invoice(invoice_2.invoice)
 

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

	
 
        # Cannot refund this credit note as it is already applied.
 
        with self.assertRaises(ValidationError):
 
            cn.refund()
 

	
 
    def test_money_into_void_invoice_generates_credit_note(self):
 
        invoice = self._invoice_containing_prod_1(1)
 
        invoice.void()
 

	
 
        val = invoice.invoice.value
 

	
 
        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_money_into_refunded_invoice_generates_credit_note(self):
 
        invoice = self._invoice_containing_prod_1(1)
 

	
 
        val = invoice.invoice.value
 

	
 
        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.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.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.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)
0 comments (0 inline, 0 general)