Merge branch 'payments_api_improvements'

This commit is contained in:
Christopher Neugebauer 2016-04-25 08:35:25 +10:00
commit 12e54ac5ba
7 changed files with 157 additions and 108 deletions

View file

@ -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.

View file

@ -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

View file

@ -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

View file

@ -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. '''

View file

@ -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(

View file

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

View file

@ -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,