Merge branch 'refunds'

This commit is contained in:
Christopher Neugebauer 2016-03-28 11:50:31 +11:00
commit c790d5afd0
13 changed files with 287 additions and 19 deletions

View file

@ -139,6 +139,7 @@ class CartController(object):
# It's not valid for users to re-enter a voucher they already have # It's not valid for users to re-enter a voucher they already have
user_carts_with_voucher = rego.Cart.objects.filter( user_carts_with_voucher = rego.Cart.objects.filter(
user=self.cart.user, user=self.cart.user,
released=False,
vouchers=voucher, vouchers=voucher,
) )
if len(user_carts_with_voucher) > 0: if len(user_carts_with_voucher) > 0:

View file

@ -44,7 +44,7 @@ class CategoryConditionController(ConditionController):
''' returns True if the user has a product from a category that invokes ''' returns True if the user has a product from a category that invokes
this condition in one of their carts ''' this condition in one of their carts '''
carts = rego.Cart.objects.filter(user=user) carts = rego.Cart.objects.filter(user=user, released=False)
enabling_products = rego.Product.objects.filter( enabling_products = rego.Product.objects.filter(
category=self.condition.enabling_category) category=self.condition.enabling_category)
products = rego.ProductItem.objects.filter( products = rego.ProductItem.objects.filter(
@ -64,7 +64,7 @@ class ProductConditionController(ConditionController):
''' returns True if the user has a product that invokes this ''' returns True if the user has a product that invokes this
condition in one of their carts ''' condition in one of their carts '''
carts = rego.Cart.objects.filter(user=user) carts = rego.Cart.objects.filter(user=user, released=False)
products = rego.ProductItem.objects.filter( products = rego.ProductItem.objects.filter(
cart=carts, cart=carts,
product=self.condition.enabling_products.all()) product=self.condition.enabling_products.all())

View file

@ -12,6 +12,11 @@ class DiscountAndQuantity(object):
self.clause = clause self.clause = clause
self.quantity = quantity self.quantity = quantity
def __repr__(self):
print "(discount=%s, clause=%s, quantity=%d)" % (
self.discount, self.clause, self.quantity,
)
def available_discounts(user, categories, products): def available_discounts(user, categories, products):
''' Returns all discounts available to this user for the given categories ''' Returns all discounts available to this user for the given categories
@ -57,6 +62,7 @@ def available_discounts(user, categories, products):
past_uses = rego.DiscountItem.objects.filter( past_uses = rego.DiscountItem.objects.filter(
cart__user=user, cart__user=user,
cart__active=False, # Only past carts count cart__active=False, # Only past carts count
cart__released=False, # You can reuse refunded discounts
discount=discount.discount, discount=discount.discount,
) )
agg = past_uses.aggregate(Sum("quantity")) agg = past_uses.aggregate(Sum("quantity"))

View file

@ -1,5 +1,7 @@
from decimal import Decimal from decimal import Decimal
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
from django.core.exceptions import ValidationError
from django.db import transaction
from django.db.models import Sum from django.db.models import Sum
from registrasion import models as rego from registrasion import models as rego
@ -11,6 +13,7 @@ class InvoiceController(object):
def __init__(self, invoice): def __init__(self, invoice):
self.invoice = invoice self.invoice = invoice
self.update_validity() # Make sure this invoice is up-to-date
@classmethod @classmethod
def for_cart(cls, cart): def for_cart(cls, cart):
@ -20,10 +23,17 @@ class InvoiceController(object):
try: try:
invoice = rego.Invoice.objects.get( invoice = rego.Invoice.objects.get(
cart=cart, cart_revision=cart.revision) cart=cart,
cart_revision=cart.revision,
void=False,
)
except ObjectDoesNotExist: except ObjectDoesNotExist:
cart_controller = CartController(cart) cart_controller = CartController(cart)
cart_controller.validate_cart() # Raises ValidationError on fail. cart_controller.validate_cart() # Raises ValidationError on fail.
# Void past invoices for this cart
rego.Invoice.objects.filter(cart=cart).update(void=True)
invoice = cls._generate(cart) invoice = cls._generate(cart)
return InvoiceController(invoice) return InvoiceController(invoice)
@ -91,29 +101,37 @@ class InvoiceController(object):
return invoice return invoice
def is_valid(self): def update_validity(self):
''' Returns true if the attached invoice is not void and it represents ''' Updates the validity of this invoice if the cart it is attached to
a valid cart. ''' has updated. '''
if self.invoice.void:
return False
if self.invoice.cart is not None: if self.invoice.cart is not None:
if self.invoice.cart.revision != self.invoice.cart_revision: if self.invoice.cart.revision != self.invoice.cart_revision:
return False self.void()
return True
def void(self): def void(self):
''' Voids the invoice. ''' ''' Voids the invoice if it is valid to do so. '''
if self.invoice.paid:
raise ValidationError("Paid invoices cannot be voided, "
"only refunded.")
self.invoice.void = True self.invoice.void = True
self.invoice.save()
@transaction.atomic
def pay(self, reference, amount): def pay(self, reference, amount):
''' Pays the invoice by the given amount. If the payment ''' Pays the invoice by the given amount. If the payment
equals the total on the invoice, finalise the invoice. equals the total on the invoice, finalise the invoice.
(NB should be transactional.) (NB should be transactional.)
''' '''
if self.invoice.cart is not None: if self.invoice.cart:
cart = CartController(self.invoice.cart) cart = CartController(self.invoice.cart)
cart.validate_cart() # Raises ValidationError if invalid cart.validate_cart() # Raises ValidationError if invalid
if self.invoice.void:
raise ValidationError("Void invoices cannot be paid")
if self.invoice.paid:
raise ValidationError("Paid invoices cannot be paid again")
''' Adds a payment ''' ''' Adds a payment '''
payment = rego.Payment.objects.create( payment = rego.Payment.objects.create(
invoice=self.invoice, invoice=self.invoice,
@ -129,8 +147,36 @@ class InvoiceController(object):
if total == self.invoice.value: if total == self.invoice.value:
self.invoice.paid = True self.invoice.paid = True
cart = self.invoice.cart if self.invoice.cart:
cart.active = False cart = self.invoice.cart
cart.save() cart.active = False
cart.save()
self.invoice.save() self.invoice.save()
@transaction.atomic
def refund(self, reference, amount):
''' Refunds the invoice by the given amount. The invoice is
marked as unpaid, and the underlying cart is marked as released.
'''
if self.invoice.void:
raise ValidationError("Void invoices cannot be refunded")
''' Adds a payment '''
payment = rego.Payment.objects.create(
invoice=self.invoice,
reference=reference,
amount=0 - amount,
)
payment.save()
self.invoice.paid = False
self.invoice.void = True
if self.invoice.cart:
cart = self.invoice.cart
cart.released = True
cart.save()
self.invoice.save()

View file

@ -0,0 +1,19 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('registrasion', '0007_auto_20160326_2105'),
]
operations = [
migrations.AddField(
model_name='cart',
name='released',
field=models.BooleanField(default=False),
),
]

View file

@ -413,6 +413,7 @@ class Cart(models.Model):
reservation_duration = models.DurationField() reservation_duration = models.DurationField()
revision = models.PositiveIntegerField(default=1) revision = models.PositiveIntegerField(default=1)
active = models.BooleanField(default=True) active = models.BooleanField(default=True)
released = models.BooleanField(default=False) # Refunds etc
@classmethod @classmethod
def reserved_carts(cls): def reserved_carts(cls):
@ -422,7 +423,7 @@ class Cart(models.Model):
Q(time_last_updated__gt=( Q(time_last_updated__gt=(
timezone.now()-F('reservation_duration') timezone.now()-F('reservation_duration')
))) | ))) |
Q(active=False) (Q(active=False) & Q(released=False))
) )

View file

@ -52,7 +52,7 @@ class RegistrationCartTestCase(SetTimeMixin, TestCase):
cls.products = [] cls.products = []
for i in xrange(4): for i in xrange(4):
prod = rego.Product.objects.create( prod = rego.Product.objects.create(
name="Product 1", name="Product " + str(i + 1),
description="This is a test product.", description="This is a test product.",
category=cls.categories[i / 2], # 2 products per category category=cls.categories[i / 2], # 2 products per category
price=Decimal("10.00"), price=Decimal("10.00"),

View file

@ -132,3 +132,21 @@ class CeilingsTestCases(RegistrationCartTestCase):
self.add_timedelta(self.RESERVATION + datetime.timedelta(seconds=1)) self.add_timedelta(self.RESERVATION + datetime.timedelta(seconds=1))
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):
first_cart.validate_cart() first_cart.validate_cart()
def test_items_released_from_ceiling_by_refund(self):
self.make_ceiling("Limit ceiling", limit=1)
first_cart = CartController.for_user(self.USER_1)
first_cart.add_to_cart(self.PROD_1, 1)
first_cart.cart.active = False
first_cart.cart.save()
second_cart = CartController.for_user(self.USER_2)
with self.assertRaises(ValidationError):
second_cart.add_to_cart(self.PROD_1, 1)
first_cart.cart.released = True
first_cart.cart.save()
second_cart.add_to_cart(self.PROD_1, 1)

View file

@ -377,3 +377,27 @@ class DiscountTestCase(RegistrationCartTestCase):
[self.PROD_3, self.PROD_4], [self.PROD_3, self.PROD_4],
) )
self.assertEqual(2, len(discounts)) self.assertEqual(2, len(discounts))
def test_discounts_are_released_by_refunds(self):
self.add_discount_prod_1_includes_prod_2(quantity=2)
cart = CartController.for_user(self.USER_1)
cart.add_to_cart(self.PROD_1, 1) # Enable the discount
discounts = discount.available_discounts(self.USER_1, [], [self.PROD_2])
self.assertEqual(1, len(discounts))
cart.cart.active = False # Keep discount enabled
cart.cart.save()
cart = CartController.for_user(self.USER_1)
cart.add_to_cart(self.PROD_2, 2) # The discount will be exhausted
cart.cart.active = False
cart.cart.save()
discounts = discount.available_discounts(self.USER_1, [], [self.PROD_2])
self.assertEqual(0, len(discounts))
cart.cart.released = True
cart.cart.save()
discounts = discount.available_discounts(self.USER_1, [], [self.PROD_2])
self.assertEqual(1, len(discounts))

View file

@ -233,3 +233,41 @@ class EnablingConditionTestCases(RegistrationCartTestCase):
self.assertTrue(self.PROD_1 in prods) self.assertTrue(self.PROD_1 in prods)
self.assertTrue(self.PROD_2 in prods) self.assertTrue(self.PROD_2 in prods)
def test_category_enabling_condition_fails_if_cart_refunded(self):
self.add_category_enabling_condition(mandatory=False)
cart = CartController.for_user(self.USER_1)
cart.add_to_cart(self.PROD_3, 1)
cart.cart.active = False
cart.cart.save()
cart_2 = CartController.for_user(self.USER_1)
cart_2.add_to_cart(self.PROD_1, 1)
cart_2.set_quantity(self.PROD_1, 0)
cart.cart.released = True
cart.cart.save()
with self.assertRaises(ValidationError):
cart_2.set_quantity(self.PROD_1, 1)
def test_product_enabling_condition_fails_if_cart_refunded(self):
self.add_product_enabling_condition(mandatory=False)
cart = CartController.for_user(self.USER_1)
cart.add_to_cart(self.PROD_2, 1)
cart.cart.active = False
cart.cart.save()
cart_2 = CartController.for_user(self.USER_1)
cart_2.add_to_cart(self.PROD_1, 1)
cart_2.set_quantity(self.PROD_1, 0)
cart.cart.released = True
cart.cart.save()
with self.assertRaises(ValidationError):
cart_2.set_quantity(self.PROD_1, 1)

View file

@ -27,11 +27,17 @@ class InvoiceTestCase(RegistrationCartTestCase):
# That invoice should have a value equal to cost of PROD_1 # That invoice should have a value equal to cost of PROD_1
self.assertEqual(self.PROD_1.price, invoice_1.invoice.value) self.assertEqual(self.PROD_1.price, invoice_1.invoice.value)
# Adding item to cart should void all active invoices and produce # Adding item to cart should produce a new invoice
# a new invoice
current_cart.add_to_cart(self.PROD_2, 1) current_cart.add_to_cart(self.PROD_2, 1)
invoice_2 = InvoiceController.for_cart(current_cart.cart) invoice_2 = InvoiceController.for_cart(current_cart.cart)
self.assertNotEqual(invoice_1.invoice, invoice_2.invoice) self.assertNotEqual(invoice_1.invoice, invoice_2.invoice)
# The old invoice should automatically be voided
invoice_1_new = rego.Invoice.objects.get(pk=invoice_1.invoice.id)
invoice_2_new = rego.Invoice.objects.get(pk=invoice_2.invoice.id)
self.assertTrue(invoice_1_new.void)
self.assertFalse(invoice_2_new.void)
# Invoice should have two line items # Invoice should have two line items
line_items = rego.LineItem.objects.filter(invoice=invoice_2.invoice) line_items = rego.LineItem.objects.filter(invoice=invoice_2.invoice)
self.assertEqual(2, len(line_items)) self.assertEqual(2, len(line_items))
@ -104,3 +110,62 @@ class InvoiceTestCase(RegistrationCartTestCase):
self.assertEqual( self.assertEqual(
self.PROD_1.price * Decimal("0.5"), self.PROD_1.price * Decimal("0.5"),
invoice_1.invoice.value) invoice_1.invoice.value)
def test_invoice_voids_self_if_cart_is_invalid(self):
current_cart = CartController.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 = InvoiceController.for_cart(current_cart.cart)
self.assertFalse(invoice_1.invoice.void)
# Adding item to cart should produce a new invoice
current_cart.add_to_cart(self.PROD_2, 1)
invoice_2 = InvoiceController.for_cart(current_cart.cart)
self.assertNotEqual(invoice_1.invoice, invoice_2.invoice)
# Viewing invoice_1's invoice should show it as void
invoice_1_new = InvoiceController(invoice_1.invoice)
self.assertTrue(invoice_1_new.invoice.void)
# Viewing invoice_2's invoice should *not* show it as void
invoice_2_new = InvoiceController(invoice_2.invoice)
self.assertFalse(invoice_2_new.invoice.void)
def test_voiding_invoice_creates_new_invoice(self):
current_cart = CartController.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 = InvoiceController.for_cart(current_cart.cart)
self.assertFalse(invoice_1.invoice.void)
invoice_1.void()
invoice_2 = InvoiceController.for_cart(current_cart.cart)
self.assertNotEqual(invoice_1.invoice, invoice_2.invoice)
def test_cannot_pay_void_invoice(self):
current_cart = CartController.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 = InvoiceController.for_cart(current_cart.cart)
invoice_1.void()
with self.assertRaises(ValidationError):
invoice_1.pay("Reference", invoice_1.invoice.value)
def test_cannot_void_paid_invoice(self):
current_cart = CartController.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 = InvoiceController.for_cart(current_cart.cart)
invoice_1.pay("Reference", invoice_1.invoice.value)
with self.assertRaises(ValidationError):
invoice_1.void()

View file

@ -0,0 +1,33 @@
import datetime
import pytz
from decimal import Decimal
from django.core.exceptions import ValidationError
from registrasion import models as rego
from registrasion.controllers.cart import CartController
from registrasion.controllers.invoice import InvoiceController
from test_cart import RegistrationCartTestCase
UTC = pytz.timezone('UTC')
class RefundTestCase(RegistrationCartTestCase):
def test_refund_marks_void_and_unpaid_and_cart_released(self):
current_cart = CartController.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 = InvoiceController.for_cart(current_cart.cart)
invoice.pay("A Payment!", invoice.invoice.value)
self.assertFalse(invoice.invoice.void)
self.assertTrue(invoice.invoice.paid)
self.assertFalse(invoice.invoice.cart.released)
invoice.refund("A Refund!", invoice.invoice.value)
self.assertTrue(invoice.invoice.void)
self.assertFalse(invoice.invoice.paid)
self.assertTrue(invoice.invoice.cart.released)

View file

@ -126,3 +126,20 @@ class VoucherTestCases(RegistrationCartTestCase):
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):
current_cart.apply_voucher(voucher.code) current_cart.apply_voucher(voucher.code)
return current_cart
def test_refund_releases_used_vouchers(self):
voucher = self.new_voucher(limit=2)
current_cart = CartController.for_user(self.USER_1)
current_cart.apply_voucher(voucher.code)
inv = InvoiceController.for_cart(current_cart.cart)
inv.pay("Hello!", inv.invoice.value)
current_cart = CartController.for_user(self.USER_1)
with self.assertRaises(ValidationError):
current_cart.apply_voucher(voucher.code)
inv.refund("Hello!", inv.invoice.value)
current_cart.apply_voucher(voucher.code)