Merge branch 'refunds'
This commit is contained in:
commit
c790d5afd0
13 changed files with 287 additions and 19 deletions
|
@ -139,6 +139,7 @@ class CartController(object):
|
|||
# It's not valid for users to re-enter a voucher they already have
|
||||
user_carts_with_voucher = rego.Cart.objects.filter(
|
||||
user=self.cart.user,
|
||||
released=False,
|
||||
vouchers=voucher,
|
||||
)
|
||||
if len(user_carts_with_voucher) > 0:
|
||||
|
|
|
@ -44,7 +44,7 @@ class CategoryConditionController(ConditionController):
|
|||
''' returns True if the user has a product from a category that invokes
|
||||
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(
|
||||
category=self.condition.enabling_category)
|
||||
products = rego.ProductItem.objects.filter(
|
||||
|
@ -64,7 +64,7 @@ class ProductConditionController(ConditionController):
|
|||
''' returns True if the user has a product that invokes this
|
||||
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(
|
||||
cart=carts,
|
||||
product=self.condition.enabling_products.all())
|
||||
|
|
|
@ -12,6 +12,11 @@ class DiscountAndQuantity(object):
|
|||
self.clause = clause
|
||||
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):
|
||||
''' 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(
|
||||
cart__user=user,
|
||||
cart__active=False, # Only past carts count
|
||||
cart__released=False, # You can reuse refunded discounts
|
||||
discount=discount.discount,
|
||||
)
|
||||
agg = past_uses.aggregate(Sum("quantity"))
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
from decimal import Decimal
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import transaction
|
||||
from django.db.models import Sum
|
||||
|
||||
from registrasion import models as rego
|
||||
|
@ -11,6 +13,7 @@ class InvoiceController(object):
|
|||
|
||||
def __init__(self, invoice):
|
||||
self.invoice = invoice
|
||||
self.update_validity() # Make sure this invoice is up-to-date
|
||||
|
||||
@classmethod
|
||||
def for_cart(cls, cart):
|
||||
|
@ -20,10 +23,17 @@ class InvoiceController(object):
|
|||
|
||||
try:
|
||||
invoice = rego.Invoice.objects.get(
|
||||
cart=cart, cart_revision=cart.revision)
|
||||
cart=cart,
|
||||
cart_revision=cart.revision,
|
||||
void=False,
|
||||
)
|
||||
except ObjectDoesNotExist:
|
||||
cart_controller = CartController(cart)
|
||||
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)
|
||||
|
||||
return InvoiceController(invoice)
|
||||
|
@ -91,29 +101,37 @@ class InvoiceController(object):
|
|||
|
||||
return invoice
|
||||
|
||||
def is_valid(self):
|
||||
''' Returns true if the attached invoice is not void and it represents
|
||||
a valid cart. '''
|
||||
if self.invoice.void:
|
||||
return False
|
||||
def update_validity(self):
|
||||
''' Updates the validity of this invoice if the cart it is attached to
|
||||
has updated. '''
|
||||
if self.invoice.cart is not None:
|
||||
if self.invoice.cart.revision != self.invoice.cart_revision:
|
||||
return False
|
||||
return True
|
||||
self.void()
|
||||
|
||||
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.save()
|
||||
|
||||
@transaction.atomic
|
||||
def pay(self, reference, amount):
|
||||
''' Pays the invoice by the given amount. If the payment
|
||||
equals the total on the invoice, finalise the invoice.
|
||||
(NB should be transactional.)
|
||||
'''
|
||||
if self.invoice.cart is not None:
|
||||
if self.invoice.cart:
|
||||
cart = CartController(self.invoice.cart)
|
||||
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 '''
|
||||
payment = rego.Payment.objects.create(
|
||||
invoice=self.invoice,
|
||||
|
@ -129,8 +147,36 @@ class InvoiceController(object):
|
|||
if total == self.invoice.value:
|
||||
self.invoice.paid = True
|
||||
|
||||
if self.invoice.cart:
|
||||
cart = self.invoice.cart
|
||||
cart.active = False
|
||||
cart.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()
|
||||
|
|
19
registrasion/migrations/0008_cart_released.py
Normal file
19
registrasion/migrations/0008_cart_released.py
Normal 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),
|
||||
),
|
||||
]
|
|
@ -413,6 +413,7 @@ class Cart(models.Model):
|
|||
reservation_duration = models.DurationField()
|
||||
revision = models.PositiveIntegerField(default=1)
|
||||
active = models.BooleanField(default=True)
|
||||
released = models.BooleanField(default=False) # Refunds etc
|
||||
|
||||
@classmethod
|
||||
def reserved_carts(cls):
|
||||
|
@ -422,7 +423,7 @@ class Cart(models.Model):
|
|||
Q(time_last_updated__gt=(
|
||||
timezone.now()-F('reservation_duration')
|
||||
))) |
|
||||
Q(active=False)
|
||||
(Q(active=False) & Q(released=False))
|
||||
)
|
||||
|
||||
|
||||
|
|
|
@ -52,7 +52,7 @@ class RegistrationCartTestCase(SetTimeMixin, TestCase):
|
|||
cls.products = []
|
||||
for i in xrange(4):
|
||||
prod = rego.Product.objects.create(
|
||||
name="Product 1",
|
||||
name="Product " + str(i + 1),
|
||||
description="This is a test product.",
|
||||
category=cls.categories[i / 2], # 2 products per category
|
||||
price=Decimal("10.00"),
|
||||
|
|
|
@ -132,3 +132,21 @@ class CeilingsTestCases(RegistrationCartTestCase):
|
|||
self.add_timedelta(self.RESERVATION + datetime.timedelta(seconds=1))
|
||||
with self.assertRaises(ValidationError):
|
||||
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)
|
||||
|
|
|
@ -377,3 +377,27 @@ class DiscountTestCase(RegistrationCartTestCase):
|
|||
[self.PROD_3, self.PROD_4],
|
||||
)
|
||||
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))
|
||||
|
|
|
@ -233,3 +233,41 @@ class EnablingConditionTestCases(RegistrationCartTestCase):
|
|||
|
||||
self.assertTrue(self.PROD_1 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)
|
||||
|
|
|
@ -27,11 +27,17 @@ class InvoiceTestCase(RegistrationCartTestCase):
|
|||
# That invoice should have a value equal to cost of PROD_1
|
||||
self.assertEqual(self.PROD_1.price, invoice_1.invoice.value)
|
||||
|
||||
# Adding item to cart should void all active invoices and produce
|
||||
# a new invoice
|
||||
# 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)
|
||||
|
||||
# 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
|
||||
line_items = rego.LineItem.objects.filter(invoice=invoice_2.invoice)
|
||||
self.assertEqual(2, len(line_items))
|
||||
|
@ -104,3 +110,62 @@ class InvoiceTestCase(RegistrationCartTestCase):
|
|||
self.assertEqual(
|
||||
self.PROD_1.price * Decimal("0.5"),
|
||||
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()
|
||||
|
|
33
registrasion/tests/test_refund.py
Normal file
33
registrasion/tests/test_refund.py
Normal 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)
|
|
@ -126,3 +126,20 @@ class VoucherTestCases(RegistrationCartTestCase):
|
|||
|
||||
with self.assertRaises(ValidationError):
|
||||
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)
|
||||
|
|
Loading…
Reference in a new issue