diff --git a/docs/payments.rst b/docs/payments.rst index 51ff01da..f671895b 100644 --- a/docs/payments.rst +++ b/docs/payments.rst @@ -12,6 +12,26 @@ Registrasion also keeps track of money that is not currently attached to invoice Finally, Registrasion provides a `manual payments`_ feature, which allows for staff members to manually report payments into the system. This is the only payment facility built into Registrasion, but it's not intended as a reference implementation. +Invoice and payment access control +---------------------------------- + +Conferences are interesting: usually you want attendees to fill in their own registration so that they get their catering options right, so that they can personally agree to codes of conduct, and so that you can make sure that you're communicating key information directly with them. + +On the other hand, employees at companies often need for their employers to directly pay for their registration. + +Registrasion solves this problem by having attendees complete their own registration, and then providing an access URL that allows anyone who holds that URL to view their invoice and make payment. + +You can call ``InvoiceController.can_view`` to determine whether or not you're allowed to show the invoice. It returns true if the user is allowed to view the invoice:: + + InvoiceController.can_view(self, user=request.user, access_code="CODE") + +As a rule, you should call ``can_view`` before doing any operations that amend the status of an invoice. This includes taking payments or requesting refunds. + +The access code is unique for each attendee -- this means that every invoice that an attendee generates can be viewed with the same access code. This is useful if the user amends their registration between giving the URL to their employer, and their employer making payment. + + + + Making payments --------------- @@ -32,10 +52,7 @@ Our the ``demopay`` view from the ``registrasion-demo`` project implements pre-v from registrasion.controllers.invoice import InvoiceController from django.core.exceptions import ValidationError - # Get the Registrasion Invoice model - inv = get_object_or_404(rego.Invoice.objects, pk=invoice_id) - - invoice = InvoiceController(inv) + invoice = InvoiceController.for_id_or_404(invoice.id) try: invoice.validate_allowed_to_pay() # Verify that we're allowed to do this. @@ -84,7 +101,7 @@ Calling ``update_status`` collects the ``PaymentBase`` objects that are attached When your invoice becomes ``PAID`` for the first time, if there's a cart of inventory items attached to it, that cart becomes permanently reserved -- that is, all of the items within it are no longer available for other users to purchase. If an invoice becomes ``REFUNDED``, the items in the cart are released, which means that they are available for anyone to purchase again. -(One GitHub Issue #37 is completed) If you overpay an invoice, or pay into an invoice that should not have funds attached, a credit note for the residual payments will also be issued. +If you overpay an invoice, or pay into an invoice that should not have funds attached, a credit note for the residual payments will also be issued. In general, although this means you *can* use negative payments to take an invoice into a *REFUNDED* state, it's still much more sensible to use the credit notes facility, as this makes sure that any leftover funds remain tracked in the system. diff --git a/registrasion/controllers/credit_note.py b/registrasion/controllers/credit_note.py index e1f0ed2b..182c10e9 100644 --- a/registrasion/controllers/credit_note.py +++ b/registrasion/controllers/credit_note.py @@ -2,8 +2,12 @@ from django.db import transaction from registrasion.models import commerce +from for_id import ForId -class CreditNoteController(object): + +class CreditNoteController(ForId, object): + + __MODEL__ = commerce.CreditNote def __init__(self, credit_note): self.credit_note = credit_note diff --git a/registrasion/controllers/for_id.py b/registrasion/controllers/for_id.py new file mode 100644 index 00000000..3748b151 --- /dev/null +++ b/registrasion/controllers/for_id.py @@ -0,0 +1,24 @@ +from django.core.exceptions import ObjectDoesNotExist +from django.http import Http404 + + +class ForId(object): + ''' Mixin class that gives you new classmethods: for_id for_id_or_404. + These let you retrieve an instance of the class by specifying the model ID. + + Your subclass must define __MODEL__ as a class attribute. This will be the + model class that we wrap. There must also be a constructor that takes a + single argument: the instance of the model that we are controlling. ''' + + @classmethod + def for_id(cls, id_): + id_ = int(id_) + obj = cls.__MODEL__.objects.get(pk=id_) + return cls(obj) + + @classmethod + def for_id_or_404(cls, id_): + try: + return cls.for_id(id_) + except ObjectDoesNotExist: + return Http404 diff --git a/registrasion/controllers/invoice.py b/registrasion/controllers/invoice.py index fff97e8d..ef4ee320 100644 --- a/registrasion/controllers/invoice.py +++ b/registrasion/controllers/invoice.py @@ -11,9 +11,12 @@ from registrasion.models import people from cart import CartController from credit_note import CreditNoteController +from for_id import ForId -class InvoiceController(object): +class InvoiceController(ForId, object): + + __MODEL__ = commerce.Invoice def __init__(self, invoice): self.invoice = invoice @@ -193,11 +196,6 @@ class InvoiceController(object): # 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() @@ -213,6 +211,17 @@ class InvoiceController(object): # Should not ever change from here pass + # Generate credit notes from residual payments + residual = 0 + if self.invoice.is_paid: + if remainder < 0: + residual = 0 - remainder + elif self.invoice.is_void or self.invoice.is_refunded: + residual = total_paid + + if residual != 0: + CreditNoteController.generate_from_invoice(self.invoice, residual) + def _mark_paid(self): ''' Marks the invoice as paid, and updates the attached cart if necessary. ''' diff --git a/registrasion/tests/controller_helpers.py b/registrasion/tests/controller_helpers.py index ac676c03..3ede49c2 100644 --- a/registrasion/tests/controller_helpers.py +++ b/registrasion/tests/controller_helpers.py @@ -34,11 +34,14 @@ class TestingCartController(CartController): class TestingInvoiceController(InvoiceController): - def pay(self, reference, amount): + def pay(self, reference, amount, pre_validate=True): ''' Testing method for simulating an invoice paymenht by the given amount. ''' - self.validate_allowed_to_pay() + if pre_validate: + # Manual payments don't pre-validate; we should test that things + # still work if we do silly things. + self.validate_allowed_to_pay() ''' Adds a payment ''' commerce.ManualPayment.objects.create( diff --git a/registrasion/tests/test_invoice.py b/registrasion/tests/test_invoice.py index 3a655bb1..a7db2849 100644 --- a/registrasion/tests/test_invoice.py +++ b/registrasion/tests/test_invoice.py @@ -18,6 +18,16 @@ UTC = pytz.timezone('UTC') class InvoiceTestCase(RegistrationCartTestCase): + def _invoice_containing_prod_1(self, qty=1): + cart = TestingCartController.for_user(self.USER_1) + cart.add_to_cart(self.PROD_1, qty) + + return TestingInvoiceController.for_cart(self.reget(cart.cart)) + + def _credit_note_for_invoice(self, invoice): + note = commerce.CreditNote.objects.get(invoice=invoice) + return TestingCreditNoteController(note) + def test_create_invoice(self): current_cart = TestingCartController.for_user(self.USER_1) @@ -53,6 +63,17 @@ class InvoiceTestCase(RegistrationCartTestCase): self.PROD_1.price + self.PROD_2.price, invoice_2.invoice.value) + def test_invoice_controller_for_id_works(self): + invoice = self._invoice_containing_prod_1(1) + + id_ = invoice.invoice.id + + invoice1 = TestingInvoiceController.for_id(id_) + invoice2 = TestingInvoiceController.for_id(str(id_)) + + self.assertEqual(invoice.invoice, invoice1.invoice) + self.assertEqual(invoice.invoice, invoice2.invoice) + def test_create_invoice_fails_if_cart_invalid(self): self.make_ceiling("Limit ceiling", limit=1) self.set_time(datetime.datetime(2015, 01, 01, tzinfo=UTC)) @@ -68,10 +89,8 @@ class InvoiceTestCase(RegistrationCartTestCase): TestingInvoiceController.for_cart(current_cart.cart) def test_paying_invoice_makes_new_cart(self): - current_cart = TestingCartController.for_user(self.USER_1) - current_cart.add_to_cart(self.PROD_1, 1) + invoice = self._invoice_containing_prod_1(1) - invoice = TestingInvoiceController.for_cart(current_cart.cart) invoice.pay("A payment!", invoice.invoice.value) # This payment is for the correct amount invoice should be paid. @@ -82,7 +101,7 @@ class InvoiceTestCase(RegistrationCartTestCase): # Asking for a cart should generate a new one new_cart = TestingCartController.for_user(self.USER_1) - self.assertNotEqual(current_cart.cart, new_cart.cart) + self.assertNotEqual(invoice.invoice.cart, new_cart.cart) def test_invoice_includes_discounts(self): voucher = inventory.Voucher.objects.create( @@ -167,24 +186,16 @@ class InvoiceTestCase(RegistrationCartTestCase): self.assertFalse(invoice_2_new.invoice.is_void) def test_voiding_invoice_creates_new_invoice(self): - current_cart = TestingCartController.for_user(self.USER_1) - - # Should be able to create an invoice after the product is added - current_cart.add_to_cart(self.PROD_1, 1) - invoice_1 = TestingInvoiceController.for_cart(current_cart.cart) + invoice_1 = self._invoice_containing_prod_1(1) self.assertFalse(invoice_1.invoice.is_void) invoice_1.void() - invoice_2 = TestingInvoiceController.for_cart(current_cart.cart) + invoice_2 = TestingInvoiceController.for_cart(invoice_1.invoice.cart) self.assertNotEqual(invoice_1.invoice, invoice_2.invoice) def test_cannot_pay_void_invoice(self): - current_cart = TestingCartController.for_user(self.USER_1) - - # Should be able to create an invoice after the product is added - current_cart.add_to_cart(self.PROD_1, 1) - invoice_1 = TestingInvoiceController.for_cart(current_cart.cart) + invoice_1 = self._invoice_containing_prod_1(1) invoice_1.void() @@ -192,11 +203,7 @@ class InvoiceTestCase(RegistrationCartTestCase): invoice_1.validate_allowed_to_pay() def test_cannot_void_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 = self._invoice_containing_prod_1(1) invoice.pay("Reference", invoice.invoice.value) @@ -204,11 +211,7 @@ class InvoiceTestCase(RegistrationCartTestCase): 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 = self._invoice_containing_prod_1(1) invoice.pay("Reference", invoice.invoice.value - 1) self.assertTrue(invoice.invoice.is_unpaid) @@ -233,10 +236,7 @@ class InvoiceTestCase(RegistrationCartTestCase): invoice.validate_allowed_to_pay() def test_overpaid_invoice_results_in_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 = self._invoice_containing_prod_1(1) # Invoice is overpaid by 1 unit to_pay = invoice.invoice.value + 1 @@ -254,10 +254,7 @@ class InvoiceTestCase(RegistrationCartTestCase): 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 = self._invoice_containing_prod_1(1) # Invoice is paid evenly invoice.pay("Reference", invoice.invoice.value) @@ -273,10 +270,7 @@ class InvoiceTestCase(RegistrationCartTestCase): 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 = self._invoice_containing_prod_1(1) # Invoice is underpaid by 1 unit to_pay = invoice.invoice.value - 1 @@ -295,10 +289,7 @@ class InvoiceTestCase(RegistrationCartTestCase): 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)) + invoice = self._invoice_containing_prod_1(1) to_pay = invoice.invoice.value invoice.pay("Reference", to_pay) @@ -318,10 +309,7 @@ class InvoiceTestCase(RegistrationCartTestCase): 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)) + invoice = self._invoice_containing_prod_1(1) to_pay = invoice.invoice.value invoice.pay("Reference", to_pay) @@ -330,8 +318,7 @@ class InvoiceTestCase(RegistrationCartTestCase): invoice.refund() # There should be one credit note generated out of the invoice. - credit_note = commerce.CreditNote.objects.get(invoice=invoice.invoice) - cn = TestingCreditNoteController(credit_note) + cn = self._credit_note_for_invoice(invoice.invoice) # That credit note should be in the unclaimed pile self.assertEquals(1, commerce.CreditNote.unclaimed().count()) @@ -349,10 +336,7 @@ class InvoiceTestCase(RegistrationCartTestCase): self.assertEquals(0, commerce.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)) + invoice = self._invoice_containing_prod_1(2) to_pay = invoice.invoice.value invoice.pay("Reference", to_pay) @@ -361,8 +345,7 @@ class InvoiceTestCase(RegistrationCartTestCase): invoice.refund() # There should be one credit note generated out of the invoice. - credit_note = commerce.CreditNote.objects.get(invoice=invoice.invoice) - cn = TestingCreditNoteController(credit_note) + cn = self._credit_note_for_invoice(invoice.invoice) self.assertEquals(1, commerce.CreditNote.unclaimed().count()) @@ -391,10 +374,7 @@ class InvoiceTestCase(RegistrationCartTestCase): ) 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)) + invoice = self._invoice_containing_prod_1(1) to_pay = invoice.invoice.value invoice.pay("Reference", to_pay) @@ -403,14 +383,10 @@ class InvoiceTestCase(RegistrationCartTestCase): invoice.refund() # There should be one credit note generated out of the invoice. - credit_note = commerce.CreditNote.objects.get(invoice=invoice.invoice) - cn = TestingCreditNoteController(credit_note) + cn = self._credit_note_for_invoice(invoice.invoice) # Create a new cart with invoice, pay it - 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 = self._invoice_containing_prod_1(1) invoice_2.pay("LOL", invoice_2.invoice.value) # Cannot pay paid invoice @@ -423,20 +399,14 @@ class InvoiceTestCase(RegistrationCartTestCase): 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 = self._invoice_containing_prod_1(1) invoice_2.void() # Cannot pay void invoice with self.assertRaises(ValidationError): cn.apply_to_invoice(invoice_2.invoice) def test_cannot_apply_a_refunded_credit_note(self): - cart = TestingCartController.for_user(self.USER_1) - cart.add_to_cart(self.PROD_1, 1) - - invoice = TestingInvoiceController.for_cart(self.reget(cart.cart)) + invoice = self._invoice_containing_prod_1(1) to_pay = invoice.invoice.value invoice.pay("Reference", to_pay) @@ -446,9 +416,8 @@ class InvoiceTestCase(RegistrationCartTestCase): self.assertEquals(1, commerce.CreditNote.unclaimed().count()) - credit_note = commerce.CreditNote.objects.get(invoice=invoice.invoice) + cn = self._credit_note_for_invoice(invoice.invoice) - cn = TestingCreditNoteController(credit_note) cn.refund() # Refunding a credit note should mark it as claimed @@ -465,10 +434,7 @@ class InvoiceTestCase(RegistrationCartTestCase): 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)) + invoice = self._invoice_containing_prod_1(1) to_pay = invoice.invoice.value invoice.pay("Reference", to_pay) @@ -478,9 +444,7 @@ class InvoiceTestCase(RegistrationCartTestCase): self.assertEquals(1, commerce.CreditNote.unclaimed().count()) - credit_note = commerce.CreditNote.objects.get(invoice=invoice.invoice) - - cn = TestingCreditNoteController(credit_note) + cn = self._credit_note_for_invoice(invoice.invoice) # Create a new cart with invoice cart = TestingCartController.for_user(self.USER_1) @@ -494,3 +458,41 @@ class InvoiceTestCase(RegistrationCartTestCase): # Cannot refund this credit note as it is already applied. with self.assertRaises(ValidationError): cn.refund() + + def test_money_into_void_invoice_generates_credit_note(self): + invoice = self._invoice_containing_prod_1(1) + invoice.void() + + val = invoice.invoice.value + + invoice.pay("Paying into the void.", val, pre_validate=False) + cn = self._credit_note_for_invoice(invoice.invoice) + self.assertEqual(val, cn.credit_note.value) + + def test_money_into_refunded_invoice_generates_credit_note(self): + invoice = self._invoice_containing_prod_1(1) + + val = invoice.invoice.value + + invoice.pay("Paying the first time.", val) + invoice.refund() + + cnval = val - 1 + invoice.pay("Paying into the void.", cnval, pre_validate=False) + + notes = commerce.CreditNote.objects.filter(invoice=invoice.invoice) + notes = sorted(notes, key = lambda note: note.value) + + self.assertEqual(cnval, notes[0].value) + self.assertEqual(val, notes[1].value) + + def test_money_into_paid_invoice_generates_credit_note(self): + invoice = self._invoice_containing_prod_1(1) + + val = invoice.invoice.value + + invoice.pay("Paying the first time.", val) + + invoice.pay("Paying into the void.", val, pre_validate=False) + cn = self._credit_note_for_invoice(invoice.invoice) + self.assertEqual(val, cn.credit_note.value) diff --git a/registrasion/views.py b/registrasion/views.py index ea4acc8b..6a67a44b 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -481,10 +481,7 @@ def invoice(request, invoice_id, access_code=None): access code. ''' - invoice_id = int(invoice_id) - inv = commerce.Invoice.objects.get(pk=invoice_id) - - current_invoice = InvoiceController(inv) + current_invoice = InvoiceController.for_id_or_404(invoice_id) if not current_invoice.can_view( user=request.user, @@ -508,9 +505,7 @@ def manual_payment(request, invoice_id): if not request.user.is_staff: raise Http404() - invoice_id = int(invoice_id) - inv = get_object_or_404(commerce.Invoice, pk=invoice_id) - current_invoice = InvoiceController(inv) + current_invoice = InvoiceController.for_id_or_404(invoice_id) form = forms.ManualPaymentForm( request.POST or None, @@ -539,9 +534,7 @@ def refund(request, invoice_id): if not request.user.is_staff: raise Http404() - invoice_id = int(invoice_id) - inv = get_object_or_404(commerce.Invoice, pk=invoice_id) - current_invoice = InvoiceController(inv) + current_invoice = InvoiceController.for_id_or_404(invoice_id) try: current_invoice.refund() @@ -560,10 +553,7 @@ def credit_note(request, note_id, access_code=None): if not request.user.is_staff: raise Http404() - note_id = int(note_id) - note = commerce.CreditNote.objects.get(pk=note_id) - - current_note = CreditNoteController(note) + current_note = CreditNoteController.for_id_or_404(note_id) apply_form = forms.ApplyCreditNoteForm( note.invoice.user,