From 6b10a0a7e42764250b07a205a278a9b72227cadc Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sun, 10 Apr 2016 14:41:43 +1000 Subject: [PATCH 1/4] Adds CreditNote, CreditNoteController, related models, and tests. --- registrasion/controllers/credit_note.py | 51 ++++ registrasion/controllers/invoice.py | 34 ++- ...refund_squashed_0019_auto_20160410_0753.py | 43 +++ ...refund_squashed_0020_auto_20160411_0256.py | 27 ++ .../migrations/0020_auto_20160411_0258.py | 21 ++ registrasion/models.py | 92 ++++++ registrasion/tests/controller_helpers.py | 10 + registrasion/tests/test_invoice.py | 274 +++++++++++++++++- registrasion/tests/test_refund.py | 2 +- registrasion/tests/test_voucher.py | 2 +- 10 files changed, 534 insertions(+), 22 deletions(-) create mode 100644 registrasion/controllers/credit_note.py create mode 100644 registrasion/migrations/0018_creditnote_creditnoteapplication_creditnoterefund_squashed_0019_auto_20160410_0753.py create mode 100644 registrasion/migrations/0019_manualcreditnoterefund_squashed_0020_auto_20160411_0256.py create mode 100644 registrasion/migrations/0020_auto_20160411_0258.py diff --git a/registrasion/controllers/credit_note.py b/registrasion/controllers/credit_note.py new file mode 100644 index 00000000..bd15947d --- /dev/null +++ b/registrasion/controllers/credit_note.py @@ -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. diff --git a/registrasion/controllers/invoice.py b/registrasion/controllers/invoice.py index fce09f8f..58b0beb6 100644 --- a/registrasion/controllers/invoice.py +++ b/registrasion/controllers/invoice.py @@ -8,6 +8,7 @@ from django.utils import timezone from registrasion import models as rego from cart import CartController +from credit_note import CreditNoteController class InvoiceController(object): @@ -189,6 +190,12 @@ class InvoiceController(object): 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() @@ -247,30 +254,31 @@ class InvoiceController(object): def void(self): ''' Voids the invoice if it is valid to do so. ''' - if self.invoice.status == rego.Invoice.STATUS_PAID: - raise ValidationError("Paid invoices cannot be voided, " - "only refunded.") + 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, reference, amount): - ''' Refunds the invoice by the given amount. + 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. - TODO: replace with credit notes work instead. ''' if self.invoice.is_void: raise ValidationError("Void invoices cannot be refunded") - # Adds a payment - # TODO: replace by creating a credit note instead - rego.ManualPayment.objects.create( - invoice=self.invoice, - reference=reference, - amount=0 - amount, - ) + # 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() diff --git a/registrasion/migrations/0018_creditnote_creditnoteapplication_creditnoterefund_squashed_0019_auto_20160410_0753.py b/registrasion/migrations/0018_creditnote_creditnoteapplication_creditnoterefund_squashed_0019_auto_20160410_0753.py new file mode 100644 index 00000000..abb3b6e9 --- /dev/null +++ b/registrasion/migrations/0018_creditnote_creditnoteapplication_creditnoterefund_squashed_0019_auto_20160410_0753.py @@ -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')), + ], + ), + ] diff --git a/registrasion/migrations/0019_manualcreditnoterefund_squashed_0020_auto_20160411_0256.py b/registrasion/migrations/0019_manualcreditnoterefund_squashed_0020_auto_20160411_0256.py new file mode 100644 index 00000000..b070e3e6 --- /dev/null +++ b/registrasion/migrations/0019_manualcreditnoterefund_squashed_0020_auto_20160411_0256.py @@ -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')), + ], + ), + ] diff --git a/registrasion/migrations/0020_auto_20160411_0258.py b/registrasion/migrations/0020_auto_20160411_0258.py new file mode 100644 index 00000000..0148f90d --- /dev/null +++ b/registrasion/migrations/0020_auto_20160411_0258.py @@ -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'), + ), + ] diff --git a/registrasion/models.py b/registrasion/models.py index a1c06ca8..6e625887 100644 --- a/registrasion/models.py +++ b/registrasion/models.py @@ -648,3 +648,95 @@ class PaymentBase(models.Model): class ManualPayment(PaymentBase): ''' Payments that are manually entered by staff. ''' 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) diff --git a/registrasion/tests/controller_helpers.py b/registrasion/tests/controller_helpers.py index ad8661b6..fe41f316 100644 --- a/registrasion/tests/controller_helpers.py +++ b/registrasion/tests/controller_helpers.py @@ -1,4 +1,5 @@ from registrasion.controllers.cart import CartController +from registrasion.controllers.credit_note import CreditNoteController from registrasion.controllers.invoice import InvoiceController from registrasion import models as rego @@ -47,3 +48,12 @@ class TestingInvoiceController(InvoiceController): ) self.update_status() + + +class TestingCreditNoteController(CreditNoteController): + + def refund(self): + rego.CreditNoteRefund.objects.create( + parent=self.credit_note, + reference="Whoops." + ) diff --git a/registrasion/tests/test_invoice.py b/registrasion/tests/test_invoice.py index 7854680d..f9ea59b6 100644 --- a/registrasion/tests/test_invoice.py +++ b/registrasion/tests/test_invoice.py @@ -6,6 +6,7 @@ from django.core.exceptions import ValidationError from registrasion import models as rego from controller_helpers import TestingCartController +from controller_helpers import TestingCreditNoteController from controller_helpers import TestingInvoiceController from test_cart import RegistrationCartTestCase @@ -187,12 +188,25 @@ class InvoiceTestCase(RegistrationCartTestCase): # 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) + 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): - 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): current_cart = TestingCartController.for_user(self.USER_1) @@ -210,9 +224,255 @@ class InvoiceTestCase(RegistrationCartTestCase): with self.assertRaises(ValidationError): invoice.validate_allowed_to_pay() - # TODO: test partially paid invoice cannot be void until payments - # are refunded + def test_overpaid_invoice_results_in_credit_note(self): + 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() diff --git a/registrasion/tests/test_refund.py b/registrasion/tests/test_refund.py index 35457749..fbed1f32 100644 --- a/registrasion/tests/test_refund.py +++ b/registrasion/tests/test_refund.py @@ -23,7 +23,7 @@ class RefundTestCase(RegistrationCartTestCase): self.assertFalse(invoice.invoice.is_refunded) 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_paid) self.assertTrue(invoice.invoice.is_refunded) diff --git a/registrasion/tests/test_voucher.py b/registrasion/tests/test_voucher.py index be598206..d4614efb 100644 --- a/registrasion/tests/test_voucher.py +++ b/registrasion/tests/test_voucher.py @@ -148,7 +148,7 @@ class VoucherTestCases(RegistrationCartTestCase): with self.assertRaises(ValidationError): current_cart.apply_voucher(voucher.code) - inv.refund("Hello!", inv.invoice.value) + inv.refund() current_cart.apply_voucher(voucher.code) def test_fix_simple_errors_does_not_remove_limited_voucher(self): From 2c94e7538a6e426ef4c91cd94bd6282865cd9b56 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Mon, 11 Apr 2016 11:07:24 +1000 Subject: [PATCH 2/4] Adds available_credit tag, and adds a view for refunding an invoice to generate a credit note. --- .../templatetags/registrasion_tags.py | 10 +++++++++ registrasion/urls.py | 2 ++ registrasion/views.py | 21 +++++++++++++++++++ 3 files changed, 33 insertions(+) diff --git a/registrasion/templatetags/registrasion_tags.py b/registrasion/templatetags/registrasion_tags.py index 7d618171..07ea7c14 100644 --- a/registrasion/templatetags/registrasion_tags.py +++ b/registrasion/templatetags/registrasion_tags.py @@ -16,6 +16,16 @@ def available_categories(context): return CategoryController.available_categories(context.request.user) +@register.assignment_tag(takes_context=True) +def available_credit(context): + ''' Returns the amount of unclaimed credit available for this user. ''' + notes = rego.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 all of the invoices that this user has. ''' diff --git a/registrasion/urls.py b/registrasion/urls.py index 0949e4b4..b927edbf 100644 --- a/registrasion/urls.py +++ b/registrasion/urls.py @@ -10,6 +10,8 @@ urlpatterns = patterns( url(r"^invoice/([0-9]+)/([A-Z0-9]+)$", views.invoice, name="invoice"), url(r"^invoice/([0-9]+)/manual_payment$", views.manual_payment, name="manual_payment"), + url(r"^invoice/([0-9]+)/refund$", + views.refund, name="refund"), url(r"^invoice_access/([A-Z0-9]+)$", views.invoice_access, name="invoice_access"), url(r"^profile$", "edit_profile", name="attendee_edit"), diff --git a/registrasion/views.py b/registrasion/views.py index f0795647..509473aa 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -524,3 +524,24 @@ def manual_payment(request, invoice_id): } return render(request, "registrasion/manual_payment.html", data) + + +@login_required +def refund(request, invoice_id): + ''' Allows staff to refund payments against an invoice and request a + credit note.''' + + if not request.user.is_staff: + raise Http404() + + invoice_id = int(invoice_id) + inv = get_object_or_404(rego.Invoice, pk=invoice_id) + current_invoice = InvoiceController(inv) + + try: + current_invoice.refund() + messages.success(request, "This invoice has been refunded.") + except ValidationError as ve: + messages.error(request, ve) + + return redirect("invoice", invoice_id) From 680ce689f6358b447f5328469ecbbc97cf6b3907 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Mon, 11 Apr 2016 12:11:14 +1000 Subject: [PATCH 3/4] Adds initial credit note display view --- registrasion/urls.py | 1 + registrasion/views.py | 21 +++++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/registrasion/urls.py b/registrasion/urls.py index b927edbf..7b28693e 100644 --- a/registrasion/urls.py +++ b/registrasion/urls.py @@ -6,6 +6,7 @@ urlpatterns = patterns( "registrasion.views", url(r"^category/([0-9]+)$", "product_category", name="product_category"), url(r"^checkout$", "checkout", name="checkout"), + url(r"^credit_note/([0-9]+)$", views.credit_note, name="credit_note"), url(r"^invoice/([0-9]+)$", "invoice", name="invoice"), url(r"^invoice/([0-9]+)/([A-Z0-9]+)$", views.invoice, name="invoice"), url(r"^invoice/([0-9]+)/manual_payment$", diff --git a/registrasion/views.py b/registrasion/views.py index 509473aa..13f1b857 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -4,6 +4,7 @@ from registrasion import forms from registrasion import models as rego from registrasion.controllers import discount from registrasion.controllers.cart import CartController +from registrasion.controllers.credit_note import CreditNoteController from registrasion.controllers.invoice import InvoiceController from registrasion.controllers.product import ProductController from registrasion.exceptions import CartValidationError @@ -545,3 +546,23 @@ def refund(request, invoice_id): messages.error(request, ve) return redirect("invoice", invoice_id) + + +def credit_note(request, note_id, access_code=None): + ''' Displays an credit note for a given id. + This view can only be seen by staff. + ''' + + if not request.user.is_staff: + raise Http404() + + note_id = int(note_id) + note = rego.CreditNote.objects.get(pk=note_id) + + current_note = CreditNoteController(note) + + data = { + "credit_note": current_note.credit_note, + } + + return render(request, "registrasion/credit_note.html", data) From 7e8d044a9f515625513ccb43f56d38f109d654f9 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Mon, 11 Apr 2016 13:11:31 +1000 Subject: [PATCH 4/4] Adds the ability to apply or refund a credit note. --- registrasion/forms.py | 34 ++++++++++++++++++++++++++++++++++ registrasion/views.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/registrasion/forms.py b/registrasion/forms.py index 68c69d36..2de043f2 100644 --- a/registrasion/forms.py +++ b/registrasion/forms.py @@ -3,6 +3,40 @@ import models as rego from django import forms +class ApplyCreditNoteForm(forms.Form): + + def __init__(self, user, *a, **k): + ''' User: The user whose invoices should be made available as + choices. ''' + self.user = user + super(ApplyCreditNoteForm, self).__init__(*a, **k) + + self.fields["invoice"].choices = self._unpaid_invoices_for_user + + def _unpaid_invoices_for_user(self): + invoices = rego.Invoice.objects.filter( + status=rego.Invoice.STATUS_UNPAID, + user=self.user, + ) + + return [ + (invoice.id, "Invoice %(id)d - $%(value)d" % invoice.__dict__) + for invoice in invoices + ] + + invoice = forms.ChoiceField( + #choices=_unpaid_invoices_for_user, + required=True, + ) + + +class ManualCreditNoteRefundForm(forms.ModelForm): + + class Meta: + model = rego.ManualCreditNoteRefund + fields = ["reference"] + + class ManualPaymentForm(forms.ModelForm): class Meta: diff --git a/registrasion/views.py b/registrasion/views.py index 13f1b857..b2ca8eca 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -561,8 +561,39 @@ def credit_note(request, note_id, access_code=None): current_note = CreditNoteController(note) + apply_form = forms.ApplyCreditNoteForm( + note.invoice.user, + request.POST or None, + prefix="apply_note" + ) + + refund_form = forms.ManualCreditNoteRefundForm( + request.POST or None, + prefix="refund_note" + ) + + if request.POST and apply_form.is_valid(): + inv_id = apply_form.cleaned_data["invoice"] + invoice = rego.Invoice.objects.get(pk=inv_id) + current_note.apply_to_invoice(invoice) + messages.success(request, + "Applied credit note %d to invoice." % note_id + ) + return redirect("invoice", invoice.id) + + elif request.POST and refund_form.is_valid(): + refund_form.instance.entered_by = request.user + refund_form.instance.parent = note + refund_form.save() + messages.success(request, + "Applied manual refund to credit note." + ) + return redirect("invoice", invoice.id) + data = { "credit_note": current_note.credit_note, + "apply_form": apply_form, + "refund_form": refund_form, } return render(request, "registrasion/credit_note.html", data)