Changeset - 12e04c248fb1
[Not reviewed]
0 3 0
Christopher Neugebauer - 8 years ago 2016-04-24 22:26:54
chrisjrn@gmail.com
Credit notes are now generated when invoices are overpaid, or invoices are paid into void or refunded invoices. Closes #37.
3 files changed with 64 insertions and 18 deletions:
0 comments (0 inline, 0 general)
registrasion/controllers/invoice.py
Show inline comments
 
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.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.'''
 

	
 
        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.void_all_invoices(cart)
 
            invoice = cls._generate(cart)
 

	
 
        return cls(invoice)
 

	
 
    @classmethod
 
    def void_all_invoices(cls, cart):
 
        invoices = commerce.Invoice.objects.filter(cart=cart).all()
 
        for invoice in invoices:
 
            cls(invoice).void()
 

	
 
    @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
...
 
@@ -150,116 +151,122 @@ class InvoiceController(ForId, object):
 
    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()
 

	
 
                if remainder < 0:
 
                    CreditNoteController.generate_from_invoice(
 
                        self.invoice,
 
                        0 - remainder,
 
                    )
 
            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)
 

	
 
    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 = 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.active = False
 
            cart.released = True
 
            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. '''
 
        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.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()
registrasion/tests/controller_helpers.py
Show inline comments
 
from registrasion.controllers.cart import CartController
 
from registrasion.controllers.credit_note import CreditNoteController
 
from registrasion.controllers.invoice import InvoiceController
 
from registrasion.models import commerce
 

	
 
from django.core.exceptions import ObjectDoesNotExist
 

	
 

	
 
class TestingCartController(CartController):
 

	
 
    def set_quantity(self, product, quantity, batched=False):
 
        ''' Sets the _quantity_ of the given _product_ in the cart to the given
 
        _quantity_. '''
 

	
 
        self.set_quantities(((product, quantity),))
 

	
 
    def add_to_cart(self, product, quantity):
 
        ''' Adds _quantity_ of the given _product_ to the cart. Raises
 
        ValidationError if constraints are violated.'''
 

	
 
        try:
 
            product_item = commerce.ProductItem.objects.get(
 
                cart=self.cart,
 
                product=product)
 
            old_quantity = product_item.quantity
 
        except ObjectDoesNotExist:
 
            old_quantity = 0
 
        self.set_quantity(product, old_quantity + quantity)
 

	
 
    def next_cart(self):
 
        self.cart.active = False
 
        self.cart.save()
 

	
 

	
 
class TestingInvoiceController(InvoiceController):
 

	
 
    def pay(self, reference, amount):
 
    def pay(self, reference, amount, pre_validate=True):
 
        ''' Testing method for simulating an invoice paymenht by the given
 
        amount. '''
 

	
 
        self.validate_allowed_to_pay()
 
        if pre_validate:
 
            # Manual payments don't pre-validate; we should test that things
 
            # still work if we do silly things.
 
            self.validate_allowed_to_pay()
 

	
 
        ''' Adds a payment '''
 
        commerce.ManualPayment.objects.create(
 
            invoice=self.invoice,
 
            reference=reference,
 
            amount=amount,
 
        )
 

	
 
        self.update_status()
 

	
 

	
 
class TestingCreditNoteController(CreditNoteController):
 

	
 
    def refund(self):
 
        commerce.CreditNoteRefund.objects.create(
 
            parent=self.credit_note,
 
            reference="Whoops."
 
        )
registrasion/tests/test_invoice.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_cart import RegistrationCartTestCase
 

	
 
UTC = pytz.timezone('UTC')
 

	
 

	
 
class InvoiceTestCase(RegistrationCartTestCase):
 

	
 
    def _invoice_containing_prod_1(self, qty=1):
 
        cart = TestingCartController.for_user(self.USER_1)
 
        cart.add_to_cart(self.PROD_1, qty)
 

	
 
        return TestingInvoiceController.for_cart(self.reget(cart.cart))
 

	
 
    def _credit_note_for_invoice(self, invoice):
 
        note = commerce.CreditNote.objects.get(invoice=invoice)
 
        return TestingCreditNoteController(note)
 

	
 
    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)
...
 
@@ -269,194 +273,226 @@ class InvoiceTestCase(RegistrationCartTestCase):
 
        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.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.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):
 
        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.
 
        credit_note = commerce.CreditNote.objects.get(invoice=invoice.invoice)
 
        cn = TestingCreditNoteController(credit_note)
 
        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):
 
        invoice = self._invoice_containing_prod_1(2)
 

	
 
        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.
 
        credit_note = commerce.CreditNote.objects.get(invoice=invoice.invoice)
 
        cn = TestingCreditNoteController(credit_note)
 
        cn = self._credit_note_for_invoice(invoice.invoice)
 

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

	
 
        # Create a new cart (of half value of inv 1) and get 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)
 

	
 
        # 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):
 
        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.
 
        credit_note = commerce.CreditNote.objects.get(invoice=invoice.invoice)
 
        cn = TestingCreditNoteController(credit_note)
 
        cn = self._credit_note_for_invoice(invoice.invoice)
 

	
 
        # Create a new cart with invoice, pay it
 
        invoice_2 = self._invoice_containing_prod_1(1)
 
        invoice_2.pay("LOL", invoice_2.invoice.value)
 

	
 
        # Cannot pay paid invoice
 
        with self.assertRaises(ValidationError):
 
            cn.apply_to_invoice(invoice_2.invoice)
 

	
 
        invoice_2.refund()
 
        # Cannot pay refunded invoice
 
        with self.assertRaises(ValidationError):
 
            cn.apply_to_invoice(invoice_2.invoice)
 

	
 
        # Create a new cart with invoice
 
        invoice_2 = self._invoice_containing_prod_1(1)
 
        invoice_2.void()
 
        # Cannot pay void invoice
 
        with self.assertRaises(ValidationError):
 
            cn.apply_to_invoice(invoice_2.invoice)
 

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

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

	
 
        credit_note = commerce.CreditNote.objects.get(invoice=invoice.invoice)
 
        cn = self._credit_note_for_invoice(invoice.invoice)
 

	
 
        cn = TestingCreditNoteController(credit_note)
 
        cn.refund()
 

	
 
        # Refunding a credit note should mark it as claimed
 
        self.assertEquals(0, commerce.CreditNote.unclaimed().count())
 

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

	
 
        # Cannot pay with this credit note.
 
        with self.assertRaises(ValidationError):
 
            cn.apply_to_invoice(invoice_2.invoice)
 

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

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

	
 
        credit_note = commerce.CreditNote.objects.get(invoice=invoice.invoice)
 

	
 
        cn = TestingCreditNoteController(credit_note)
 
        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))
 
        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)
0 comments (0 inline, 0 general)