Merge branch 'credit_notes'
This commit is contained in:
		
						commit
						eefdb41cfc
					
				
					 14 changed files with 654 additions and 22 deletions
				
			
		
							
								
								
									
										51
									
								
								registrasion/controllers/credit_note.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										51
									
								
								registrasion/controllers/credit_note.py
									
										
									
									
									
										Normal 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. | ||||||
|  | @ -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() | ||||||
|  |  | ||||||
|  | @ -3,6 +3,40 @@ import models as rego | ||||||
| from django import forms | 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 ManualPaymentForm(forms.ModelForm): | ||||||
| 
 | 
 | ||||||
|     class Meta: |     class Meta: | ||||||
|  |  | ||||||
|  | @ -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')), | ||||||
|  |             ], | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
|  | @ -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')), | ||||||
|  |             ], | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
							
								
								
									
										21
									
								
								registrasion/migrations/0020_auto_20160411_0258.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								registrasion/migrations/0020_auto_20160411_0258.py
									
										
									
									
									
										Normal 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'), | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
|  | @ -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) | ||||||
|  |  | ||||||
|  | @ -16,6 +16,16 @@ def available_categories(context): | ||||||
|     return CategoryController.available_categories(context.request.user) |     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) | @register.assignment_tag(takes_context=True) | ||||||
| def invoices(context): | def invoices(context): | ||||||
|     ''' Returns all of the invoices that this user has. ''' |     ''' Returns all of the invoices that this user has. ''' | ||||||
|  |  | ||||||
|  | @ -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." | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  | @ -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() | ||||||
|  |  | ||||||
|  | @ -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) | ||||||
|  |  | ||||||
|  | @ -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): | ||||||
|  |  | ||||||
|  | @ -6,10 +6,13 @@ urlpatterns = patterns( | ||||||
|     "registrasion.views", |     "registrasion.views", | ||||||
|     url(r"^category/([0-9]+)$", "product_category", name="product_category"), |     url(r"^category/([0-9]+)$", "product_category", name="product_category"), | ||||||
|     url(r"^checkout$", "checkout", name="checkout"), |     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]+)$", "invoice", name="invoice"), | ||||||
|     url(r"^invoice/([0-9]+)/([A-Z0-9]+)$", views.invoice, name="invoice"), |     url(r"^invoice/([0-9]+)/([A-Z0-9]+)$", views.invoice, name="invoice"), | ||||||
|     url(r"^invoice/([0-9]+)/manual_payment$", |     url(r"^invoice/([0-9]+)/manual_payment$", | ||||||
|         views.manual_payment, name="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, |     url(r"^invoice_access/([A-Z0-9]+)$", views.invoice_access, | ||||||
|         name="invoice_access"), |         name="invoice_access"), | ||||||
|     url(r"^profile$", "edit_profile", name="attendee_edit"), |     url(r"^profile$", "edit_profile", name="attendee_edit"), | ||||||
|  |  | ||||||
|  | @ -4,6 +4,7 @@ from registrasion import forms | ||||||
| from registrasion import models as rego | from registrasion import models as rego | ||||||
| from registrasion.controllers import discount | from registrasion.controllers import discount | ||||||
| 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.controllers.product import ProductController | from registrasion.controllers.product import ProductController | ||||||
| from registrasion.exceptions import CartValidationError | from registrasion.exceptions import CartValidationError | ||||||
|  | @ -524,3 +525,75 @@ def manual_payment(request, invoice_id): | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     return render(request, "registrasion/manual_payment.html", data) |     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) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | 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) | ||||||
|  | 
 | ||||||
|  |     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) | ||||||
|  |  | ||||||
		Loading…
	
	Add table
		
		Reference in a new issue
	
	 Christopher Neugebauer
						Christopher Neugebauer