Adds CreditNote, CreditNoteController, related models, and tests.

This commit is contained in:
Christopher Neugebauer 2016-04-10 14:41:43 +10:00
parent ae8f39381f
commit 6b10a0a7e4
10 changed files with 534 additions and 22 deletions

View file

@ -0,0 +1,51 @@
from django.db import transaction
from registrasion import models as rego
class CreditNoteController(object):
def __init__(self, credit_note):
self.credit_note = credit_note
@classmethod
def generate_from_invoice(cls, invoice, value):
''' Generates a credit note of the specified value and pays it against
the given invoice. You need to call InvoiceController.update_status()
to set the status correctly, if appropriate. '''
credit_note = rego.CreditNote.objects.create(
invoice=invoice,
amount=0-value, # Credit notes start off as a payment against inv.
reference="ONE MOMENT",
)
credit_note.reference = "Generated credit note %d" % credit_note.id
credit_note.save()
return cls(credit_note)
@transaction.atomic
def apply_to_invoice(self, invoice):
''' Applies the total value of this credit note to the specified
invoice. If this credit note overpays the invoice, a new credit note
containing the residual value will be created.
Raises ValidationError if the given invoice is not allowed to be
paid.
'''
from invoice import InvoiceController # Circular imports bleh.
inv = InvoiceController(invoice)
inv.validate_allowed_to_pay()
# Apply payment to invoice
rego.CreditNoteApplication.objects.create(
parent=self.credit_note,
invoice=invoice,
amount=self.credit_note.value,
reference="Applied credit note #%d" % self.credit_note.id,
)
inv.update_status()
# TODO: Add administration fee generator.

View file

@ -8,6 +8,7 @@ from django.utils import timezone
from registrasion import models as rego from registrasion import models as rego
from cart import CartController from cart import CartController
from credit_note import CreditNoteController
class InvoiceController(object): class InvoiceController(object):
@ -189,6 +190,12 @@ class InvoiceController(object):
if remainder <= 0: if remainder <= 0:
# Invoice no longer has amount owing # Invoice no longer has amount owing
self._mark_paid() self._mark_paid()
if remainder < 0:
CreditNoteController.generate_from_invoice(
self.invoice,
0 - remainder,
)
elif total_paid == 0 and num_payments > 0: elif total_paid == 0 and num_payments > 0:
# Invoice has multiple payments totalling zero # Invoice has multiple payments totalling zero
self._mark_void() self._mark_void()
@ -247,30 +254,31 @@ class InvoiceController(object):
def void(self): def void(self):
''' Voids the invoice if it is valid to do so. ''' ''' Voids the invoice if it is valid to do so. '''
if self.invoice.status == rego.Invoice.STATUS_PAID: if self.total_payments() > 0:
raise ValidationError("Paid invoices cannot be voided, " raise ValidationError("Invoices with payments must be refunded.")
"only refunded.") elif self.invoice.is_refunded:
raise ValidationError("Refunded invoices may not be voided.")
self._mark_void() self._mark_void()
@transaction.atomic @transaction.atomic
def refund(self, reference, amount): def refund(self):
''' Refunds the invoice by the given amount. ''' 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 The invoice is marked as refunded, and the underlying cart is marked
as released. as released.
TODO: replace with credit notes work instead.
''' '''
if self.invoice.is_void: if self.invoice.is_void:
raise ValidationError("Void invoices cannot be refunded") raise ValidationError("Void invoices cannot be refunded")
# Adds a payment # Raises a credit note fot the value of the invoice.
# TODO: replace by creating a credit note instead amount = self.total_payments()
rego.ManualPayment.objects.create(
invoice=self.invoice,
reference=reference,
amount=0 - amount,
)
if amount == 0:
self.void()
return
CreditNoteController.generate_from_invoice(self.invoice, amount)
self.update_status() self.update_status()

View file

@ -0,0 +1,43 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.9.2 on 2016-04-10 07:54
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
class Migration(migrations.Migration):
replaces = [('registrasion', '0018_creditnote_creditnoteapplication_creditnoterefund'), ('registrasion', '0019_auto_20160410_0753')]
dependencies = [
('registrasion', '0017_auto_20160408_0731'),
]
operations = [
migrations.CreateModel(
name='CreditNote',
fields=[
('paymentbase_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='registrasion.PaymentBase')),
],
bases=('registrasion.paymentbase',),
),
migrations.CreateModel(
name='CreditNoteApplication',
fields=[
('paymentbase_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='registrasion.PaymentBase')),
('parent', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='registrasion.CreditNote')),
],
bases=('registrasion.paymentbase',),
),
migrations.CreateModel(
name='CreditNoteRefund',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('time', models.DateTimeField(default=django.utils.timezone.now)),
('reference', models.CharField(max_length=255)),
('parent', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='registrasion.CreditNote')),
],
),
]

View file

@ -0,0 +1,27 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.9.2 on 2016-04-11 02:57
from __future__ import unicode_literals
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
replaces = [('registrasion', '0019_manualcreditnoterefund'), ('registrasion', '0020_auto_20160411_0256')]
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('registrasion', '0018_creditnote_creditnoteapplication_creditnoterefund_squashed_0019_auto_20160410_0753'),
]
operations = [
migrations.CreateModel(
name='ManualCreditNoteRefund',
fields=[
('entered_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
('creditnoterefund_ptr', models.OneToOneField(auto_created=True, default=0, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='registrasion.CreditNoteRefund')),
],
),
]

View file

@ -0,0 +1,21 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.9.2 on 2016-04-11 02:58
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('registrasion', '0019_manualcreditnoterefund_squashed_0020_auto_20160411_0256'),
]
operations = [
migrations.AlterField(
model_name='manualcreditnoterefund',
name='creditnoterefund_ptr',
field=models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='registrasion.CreditNoteRefund'),
),
]

View file

@ -648,3 +648,95 @@ class PaymentBase(models.Model):
class ManualPayment(PaymentBase): class ManualPayment(PaymentBase):
''' Payments that are manually entered by staff. ''' ''' Payments that are manually entered by staff. '''
pass pass
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).'''
@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
class CleanOnSave(object):
def save(self, *a, **k):
self.full_clean()
super(CleanOnSave, self).save(*a, **k)
class CreditNoteApplication(CleanOnSave, PaymentBase):
''' Represents an application of a credit note to an Invoice. '''
def clean(self):
if not hasattr(self, "parent"):
return
if hasattr(self.parent, 'creditnoterefund'):
raise ValidationError(
"Cannot apply a refunded credit note to an invoice"
)
parent = models.OneToOneField(CreditNote)
class CreditNoteRefund(CleanOnSave, models.Model):
''' Represents a refund of a credit note to an external payment.
Credit notes may only be refunded in full. How those refunds are handled
is left as an exercise to the payment app. '''
def clean(self):
if not hasattr(self, "parent"):
return
if hasattr(self.parent, 'creditnoteapplication'):
raise ValidationError(
"Cannot refund a credit note that has been paid to an invoice"
)
parent = models.OneToOneField(CreditNote)
time = models.DateTimeField(default=timezone.now)
reference = models.CharField(max_length=255)
class ManualCreditNoteRefund(CreditNoteRefund):
''' Credit notes that are entered by a staff member. '''
entered_by = models.ForeignKey(User)

View file

@ -1,4 +1,5 @@
from registrasion.controllers.cart import CartController from registrasion.controllers.cart import CartController
from registrasion.controllers.credit_note import CreditNoteController
from registrasion.controllers.invoice import InvoiceController from registrasion.controllers.invoice import InvoiceController
from registrasion import models as rego from registrasion import models as rego
@ -47,3 +48,12 @@ class TestingInvoiceController(InvoiceController):
) )
self.update_status() self.update_status()
class TestingCreditNoteController(CreditNoteController):
def refund(self):
rego.CreditNoteRefund.objects.create(
parent=self.credit_note,
reference="Whoops."
)

View file

@ -6,6 +6,7 @@ from django.core.exceptions import ValidationError
from registrasion import models as rego from registrasion import models as rego
from controller_helpers import TestingCartController from controller_helpers import TestingCartController
from controller_helpers import TestingCreditNoteController
from controller_helpers import TestingInvoiceController from controller_helpers import TestingInvoiceController
from test_cart import RegistrationCartTestCase from test_cart import RegistrationCartTestCase
@ -187,12 +188,25 @@ class InvoiceTestCase(RegistrationCartTestCase):
# Should be able to create an invoice after the product is added # Should be able to create an invoice after the product is added
current_cart.add_to_cart(self.PROD_1, 1) current_cart.add_to_cart(self.PROD_1, 1)
invoice_1 = TestingInvoiceController.for_cart(current_cart.cart) invoice = TestingInvoiceController.for_cart(current_cart.cart)
invoice_1.pay("Reference", invoice_1.invoice.value) invoice.pay("Reference", invoice.invoice.value)
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):
invoice_1.void() invoice.void()
def test_cannot_void_partially_paid_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 = TestingInvoiceController.for_cart(current_cart.cart)
invoice.pay("Reference", invoice.invoice.value - 1)
self.assertTrue(invoice.invoice.is_unpaid)
with self.assertRaises(ValidationError):
invoice.void()
def test_cannot_generate_blank_invoice(self): def test_cannot_generate_blank_invoice(self):
current_cart = TestingCartController.for_user(self.USER_1) current_cart = TestingCartController.for_user(self.USER_1)
@ -210,9 +224,255 @@ class InvoiceTestCase(RegistrationCartTestCase):
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):
invoice.validate_allowed_to_pay() invoice.validate_allowed_to_pay()
# TODO: test partially paid invoice cannot be void until payments def test_overpaid_invoice_results_in_credit_note(self):
# are refunded cart = TestingCartController.for_user(self.USER_1)
cart.add_to_cart(self.PROD_1, 1)
# TODO: test overpaid invoice results in credit note invoice = TestingInvoiceController.for_cart(self.reget(cart.cart))
# TODO: test credit note generation more generally # 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.assertTrue(invoice.invoice.is_paid)
# There should be a credit note generated out of the invoice.
credit_notes = rego.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):
cart = TestingCartController.for_user(self.USER_1)
cart.add_to_cart(self.PROD_1, 1)
invoice = TestingInvoiceController.for_cart(self.reget(cart.cart))
# 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.assertTrue(invoice.invoice.is_paid)
# There should be no credit notes
credit_notes = rego.CreditNote.objects.filter(invoice=invoice.invoice)
self.assertEqual(0, credit_notes.count())
def test_refund_partially_paid_invoice_generates_correct_credit_note(self):
cart = TestingCartController.for_user(self.USER_1)
cart.add_to_cart(self.PROD_1, 1)
invoice = TestingInvoiceController.for_cart(self.reget(cart.cart))
# 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 = rego.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):
cart = TestingCartController.for_user(self.USER_1)
cart.add_to_cart(self.PROD_1, 1)
invoice = TestingInvoiceController.for_cart(self.reget(cart.cart))
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 = rego.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):
cart = TestingCartController.for_user(self.USER_1)
cart.add_to_cart(self.PROD_1, 1)
invoice = TestingInvoiceController.for_cart(self.reget(cart.cart))
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 = rego.CreditNote.objects.get(invoice=invoice.invoice)
cn = TestingCreditNoteController(credit_note)
# That credit note should be in the unclaimed pile
self.assertEquals(1, rego.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, rego.CreditNote.unclaimed().count())
def test_apply_credit_note_generates_new_credit_note_if_overpaying(self):
cart = TestingCartController.for_user(self.USER_1)
cart.add_to_cart(self.PROD_1, 2)
invoice = TestingInvoiceController.for_cart(self.reget(cart.cart))
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 = rego.CreditNote.objects.get(invoice=invoice.invoice)
cn = TestingCreditNoteController(credit_note)
self.assertEquals(1, rego.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, rego.CreditNote.unclaimed().count())
credit_note2 = rego.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):
cart = TestingCartController.for_user(self.USER_1)
cart.add_to_cart(self.PROD_1, 1)
invoice = TestingInvoiceController.for_cart(self.reget(cart.cart))
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 = rego.CreditNote.objects.get(invoice=invoice.invoice)
cn = TestingCreditNoteController(credit_note)
# Create a new cart with invoice, pay it
cart = TestingCartController.for_user(self.USER_1)
cart.add_to_cart(self.PROD_1, 1)
invoice_2 = TestingInvoiceController.for_cart(self.reget(cart.cart))
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
cart = TestingCartController.for_user(self.USER_1)
cart.add_to_cart(self.PROD_1, 1)
invoice_2 = TestingInvoiceController.for_cart(self.reget(cart.cart))
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):
cart = TestingCartController.for_user(self.USER_1)
cart.add_to_cart(self.PROD_1, 1)
invoice = TestingInvoiceController.for_cart(self.reget(cart.cart))
to_pay = invoice.invoice.value
invoice.pay("Reference", to_pay)
self.assertTrue(invoice.invoice.is_paid)
invoice.refund()
self.assertEquals(1, rego.CreditNote.unclaimed().count())
credit_note = rego.CreditNote.objects.get(invoice=invoice.invoice)
cn = TestingCreditNoteController(credit_note)
cn.refund()
# Refunding a credit note should mark it as claimed
self.assertEquals(0, rego.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):
cart = TestingCartController.for_user(self.USER_1)
cart.add_to_cart(self.PROD_1, 1)
invoice = TestingInvoiceController.for_cart(self.reget(cart.cart))
to_pay = invoice.invoice.value
invoice.pay("Reference", to_pay)
self.assertTrue(invoice.invoice.is_paid)
invoice.refund()
self.assertEquals(1, rego.CreditNote.unclaimed().count())
credit_note = rego.CreditNote.objects.get(invoice=invoice.invoice)
cn = TestingCreditNoteController(credit_note)
# 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, rego.CreditNote.unclaimed().count())
# Cannot refund this credit note as it is already applied.
with self.assertRaises(ValidationError):
cn.refund()

View file

@ -23,7 +23,7 @@ class RefundTestCase(RegistrationCartTestCase):
self.assertFalse(invoice.invoice.is_refunded) self.assertFalse(invoice.invoice.is_refunded)
self.assertFalse(invoice.invoice.cart.released) self.assertFalse(invoice.invoice.cart.released)
invoice.refund("A Refund!", invoice.invoice.value) invoice.refund()
self.assertFalse(invoice.invoice.is_void) self.assertFalse(invoice.invoice.is_void)
self.assertFalse(invoice.invoice.is_paid) self.assertFalse(invoice.invoice.is_paid)
self.assertTrue(invoice.invoice.is_refunded) self.assertTrue(invoice.invoice.is_refunded)

View file

@ -148,7 +148,7 @@ class VoucherTestCases(RegistrationCartTestCase):
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):
current_cart.apply_voucher(voucher.code) current_cart.apply_voucher(voucher.code)
inv.refund("Hello!", inv.invoice.value) inv.refund()
current_cart.apply_voucher(voucher.code) current_cart.apply_voucher(voucher.code)
def test_fix_simple_errors_does_not_remove_limited_voucher(self): def test_fix_simple_errors_does_not_remove_limited_voucher(self):