Add JSON support for product description

This commit is contained in:
Nico Schottelius 2020-09-28 21:34:24 +02:00
parent c6bacab35a
commit c32499199a
10 changed files with 1589 additions and 653 deletions

View file

@ -11,7 +11,7 @@ from django.http import FileResponse
from django.template.loader import render_to_string from django.template.loader import render_to_string
from uncloud_pay.models import Bill, Order, BillRecord, BillingAddress, SampleOneTimeProduct, SampleRecurringProduct, SampleRecurringProductOneTimeFee from uncloud_pay.models import Bill, Order, BillRecord, BillingAddress, Product
class BillRecordInline(admin.TabularInline): class BillRecordInline(admin.TabularInline):
@ -91,7 +91,8 @@ admin.site.register(Order)
admin.site.register(BillRecord) admin.site.register(BillRecord)
admin.site.register(BillingAddress) admin.site.register(BillingAddress)
for m in [ SampleOneTimeProduct, SampleRecurringProduct, SampleRecurringProductOneTimeFee ]: #for m in [ SampleOneTimeProduct, SampleRecurringProduct, SampleRecurringProductOneTimeFee ]:
admin.site.register(m)
admin.site.register(Product)
#admin.site.register(Order, OrderAdmin) #admin.site.register(Order, OrderAdmin)

View file

@ -0,0 +1,19 @@
# Generated by Django 3.1 on 2020-09-28 19:04
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('uncloud_pay', '0016_auto_20200928_1858'),
]
operations = [
migrations.AddField(
model_name='order',
name='config',
field=models.JSONField(default={}),
preserve_default=False,
),
]

View file

@ -0,0 +1,20 @@
# Generated by Django 3.1 on 2020-09-28 19:08
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('uncloud_pay', '0017_order_config'),
]
operations = [
migrations.AddField(
model_name='order',
name='product',
field=models.ForeignKey(default=0, on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.product'),
preserve_default=False,
),
]

View file

@ -0,0 +1,17 @@
# Generated by Django 3.1 on 2020-09-28 19:14
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('uncloud_pay', '0018_order_product'),
]
operations = [
migrations.RemoveField(
model_name='product',
name='owner',
),
]

View file

@ -0,0 +1,29 @@
# Generated by Django 3.1 on 2020-09-28 19:15
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('uncloud_pay', '0019_remove_product_owner'),
]
operations = [
migrations.RemoveField(
model_name='product',
name='status',
),
migrations.AddField(
model_name='product',
name='description',
field=models.CharField(default='', max_length=1024),
preserve_default=False,
),
migrations.AddField(
model_name='product',
name='name',
field=models.CharField(default='', max_length=256),
preserve_default=False,
),
]

View file

@ -0,0 +1,23 @@
# Generated by Django 3.1 on 2020-09-28 19:32
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('uncloud_pay', '0020_auto_20200928_1915'),
]
operations = [
migrations.AddField(
model_name='product',
name='default_currency',
field=models.CharField(choices=[('CHF', 'Swiss Franc'), ('EUR', 'Euro'), ('USD', 'US Dollar')], default='CHF', max_length=32),
),
migrations.AddField(
model_name='product',
name='default_recurring_period',
field=models.IntegerField(choices=[(31536000, 'Per 365 days'), (2592000, 'Per 30 days'), (604800, 'Per Week'), (86400, 'Per Day'), (3600, 'Per Hour'), (60, 'Per Minute'), (1, 'Per Second'), (0, 'Onetime')], default=2592000),
),
]

View file

@ -0,0 +1,18 @@
# Generated by Django 3.1 on 2020-09-28 19:32
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('uncloud_pay', '0021_auto_20200928_1932'),
]
operations = [
migrations.RenameField(
model_name='product',
old_name='default_currency',
new_name='currency',
),
]

View file

@ -80,6 +80,16 @@ class RecurringPeriod(models.IntegerChoices):
PER_SECOND = 1, _('Per Second') PER_SECOND = 1, _('Per Second')
ONE_TIME = 0, _('Onetime') ONE_TIME = 0, _('Onetime')
class Currency(models.TextChoices):
"""
We don't support months are years, because they vary in length.
This is not only complicated, but also unfair to the user, as the user pays the same
amount for different durations.
"""
CHF = 'CHF', _('Swiss Franc')
EUR = 'EUR', _('Euro')
USD = 'USD', _('US Dollar')
class CountryField(models.CharField): class CountryField(models.CharField):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
kwargs.setdefault('choices', COUNTRIES) kwargs.setdefault('choices', COUNTRIES)
@ -283,6 +293,235 @@ class VATRate(models.Model):
logger.debug("Did not find VAT rate for %s, returning 0" % country_code) logger.debug("Did not find VAT rate for %s, returning 0" % country_code)
return 0 return 0
###
# Products
class Product(UncloudModel):
"""
A product is something a user can order. To record the pricing, we
create order that define a state in time.
A product can have *one* one_time_order and/or *one*
recurring_order.
If either of them needs to be updated, a new order of the same
type will be created and links to the previous order.
"""
name = models.CharField(max_length=256)
description = models.CharField(max_length=1024)
config = models.JSONField()
default_recurring_period = models.IntegerField(choices=RecurringPeriod.choices, default=RecurringPeriod.PER_30D)
currency = models.CharField(max_length=32, choices=Currency.choices, default=Currency.CHF)
@property
def recurring_orders(self):
return self.orders.order_by('id').exclude(recurring_period=RecurringPeriod.ONE_TIME)
@property
def last_recurring_order(self):
return self.recurring_orders.last()
@property
def one_time_orders(self):
return self.orders.order_by('id').filter(recurring_period=RecurringPeriod.ONE_TIME)
@property
def last_one_time_order(self):
return self.one_time_orders.last()
def create_order(self, when_to_start=None, recurring_period=None):
billing_address = BillingAddress.get_address_for(self.owner)
if not billing_address:
raise ValidationError("Cannot order without a billing address")
if not when_to_start:
when_to_start = timezone.now()
if not recurring_period:
recurring_period = self.default_recurring_period
# Create one time order if we did not create one already
if self.one_time_price > 0 and not self.last_one_time_order:
one_time_order = Order.objects.create(owner=self.owner,
billing_address=billing_address,
starting_date=when_to_start,
price=self.one_time_price,
recurring_period=RecurringPeriod.ONE_TIME,
description=str(self))
self.orders.add(one_time_order)
else:
one_time_order = None
if recurring_period != RecurringPeriod.ONE_TIME:
if one_time_order:
recurring_order = Order.objects.create(owner=self.owner,
billing_address=billing_address,
starting_date=when_to_start,
price=self.recurring_price,
recurring_period=recurring_period,
depends_on=one_time_order,
description=str(self))
else:
recurring_order = Order.objects.create(owner=self.owner,
billing_address=billing_address,
starting_date=when_to_start,
price=self.recurring_price,
recurring_period=recurring_period,
description=str(self))
self.orders.add(recurring_order)
# FIXME: this could/should be part of Order (?)
def create_or_update_recurring_order(self, when_to_start=None, recurring_period=None):
if not self.recurring_price:
return
if not recurring_period:
recurring_period = self.default_recurring_period
if not when_to_start:
when_to_start = timezone.now()
if self.last_recurring_order:
if self.recurring_price < self.last_recurring_order.price:
if when_to_start < self.last_recurring_order.next_cancel_or_downgrade_date:
when_to_start = start_after(self.last_recurring_order.next_cancel_or_downgrade_date)
when_to_end = end_before(when_to_start)
new_order = Order.objects.create(owner=self.owner,
billing_address=self.last_recurring_order.billing_address,
starting_date=when_to_start,
price=self.recurring_price,
recurring_period=recurring_period,
description=str(self),
replaces=self.last_recurring_order)
self.last_recurring_order.replace_with(new_order)
self.orders.add(new_order)
else:
self.create_order(when_to_start, recurring_period)
@property
def recurring_price(self):
""" implement correct values in the child class """
return 0
@property
def one_time_price(self):
""" implement correct values in the child class """
return 0
@property
def is_recurring(self):
return self.recurring_price > 0
@property
def billing_address(self):
return self.order.billing_address
@staticmethod
def allowed_recurring_periods():
return RecurringPeriod.choices
# class Meta:
# abstract = True
def discounted_price_by_period(self, requested_period):
"""
Each product has a standard recurring period for which
we define a pricing. I.e. VPN is usually year, VM is usually monthly.
The user can opt-in to use a different period, which influences the price:
The longer a user commits, the higher the discount.
Products can also be limited in the available periods. For instance
a VPN only makes sense to be bought for at least one day.
Rules are as follows:
given a standard recurring period of ..., changing to ... modifies price ...
# One month for free if buying / year, compared to a month: about 8.33% discount
per_year -> per_month -> /11
per_month -> per_year -> *11
# Month has 30.42 days on average. About 7.9% discount to go monthly
per_month -> per_day -> /28
per_day -> per_month -> *28
# Day has 24h, give one for free
per_day -> per_hour -> /23
per_hour -> per_day -> /23
Examples
VPN @ 120CHF/y becomes
- 10.91 CHF/month (130.91 CHF/year)
- 0.39 CHF/day (142.21 CHF/year)
VM @ 15 CHF/month becomes
- 165 CHF/month (13.75 CHF/month)
- 0.54 CHF/day (16.30 CHF/month)
"""
if self.default_recurring_period == RecurringPeriod.PER_365D:
if requested_period == RecurringPeriod.PER_365D:
return self.recurring_price
if requested_period == RecurringPeriod.PER_30D:
return self.recurring_price/11.
if requested_period == RecurringPeriod.PER_DAY:
return self.recurring_price/11./28.
elif self.default_recurring_period == RecurringPeriod.PER_30D:
if requested_period == RecurringPeriod.PER_365D:
return self.recurring_price*11
if requested_period == RecurringPeriod.PER_30D:
return self.recurring_price
if requested_period == RecurringPeriod.PER_DAY:
return self.recurring_price/28.
elif self.default_recurring_period == RecurringPeriod.PER_DAY:
if requested_period == RecurringPeriod.PER_365D:
return self.recurring_price*11*28
if requested_period == RecurringPeriod.PER_30D:
return self.recurring_price*28
if requested_period == RecurringPeriod.PER_DAY:
return self.recurring_price
else:
# FIXME: use the right type of exception here!
raise Exception("Did not implement the discounter for this case")
def save(self, *args, **kwargs):
# try:
# ba = BillingAddress.get_address_for(self.owner)
# except BillingAddress.DoesNotExist:
# raise ValidationError("User does not have a billing address")
# if not ba.active:
# raise ValidationError("User does not have an active billing address")
# Verify the required JSON fields
super().save(*args, **kwargs)
### ###
# Orders. # Orders.
@ -317,7 +556,9 @@ class Order(models.Model):
description = models.TextField() description = models.TextField()
# TODO: enforce ending_date - starting_date to be larger than recurring_period. product = models.ForeignKey(Product, blank=False, null=False, on_delete=models.CASCADE)
config = models.JSONField()
creation_date = models.DateTimeField(auto_now_add=True) creation_date = models.DateTimeField(auto_now_add=True)
starting_date = models.DateTimeField(default=timezone.now) starting_date = models.DateTimeField(default=timezone.now)
ending_date = models.DateTimeField(blank=True, null=True) ending_date = models.DateTimeField(blank=True, null=True)
@ -850,237 +1091,6 @@ class BillRecord(models.Model):
super().save(*args, **kwargs) super().save(*args, **kwargs)
###
# Products
class Product(UncloudModel):
"""
A product is something a user can order. To record the pricing, we
create order that define a state in time.
A product can have *one* one_time_order and/or *one*
recurring_order.
If either of them needs to be updated, a new order of the same
type will be created and links to the previous order.
"""
owner = models.ForeignKey(get_user_model(),
on_delete=models.CASCADE,
editable=False)
description = "Generic Product"
status = models.CharField(max_length=32,
choices=UncloudStatus.choices,
default=UncloudStatus.AWAITING_PAYMENT)
config = models.JSONField()
# Default period for all products
default_recurring_period = RecurringPeriod.PER_30D
@property
def recurring_orders(self):
return self.orders.order_by('id').exclude(recurring_period=RecurringPeriod.ONE_TIME)
@property
def last_recurring_order(self):
return self.recurring_orders.last()
@property
def one_time_orders(self):
return self.orders.order_by('id').filter(recurring_period=RecurringPeriod.ONE_TIME)
@property
def last_one_time_order(self):
return self.one_time_orders.last()
def create_order(self, when_to_start=None, recurring_period=None):
billing_address = BillingAddress.get_address_for(self.owner)
if not billing_address:
raise ValidationError("Cannot order without a billing address")
if not when_to_start:
when_to_start = timezone.now()
if not recurring_period:
recurring_period = self.default_recurring_period
# Create one time order if we did not create one already
if self.one_time_price > 0 and not self.last_one_time_order:
one_time_order = Order.objects.create(owner=self.owner,
billing_address=billing_address,
starting_date=when_to_start,
price=self.one_time_price,
recurring_period=RecurringPeriod.ONE_TIME,
description=str(self))
self.orders.add(one_time_order)
else:
one_time_order = None
if recurring_period != RecurringPeriod.ONE_TIME:
if one_time_order:
recurring_order = Order.objects.create(owner=self.owner,
billing_address=billing_address,
starting_date=when_to_start,
price=self.recurring_price,
recurring_period=recurring_period,
depends_on=one_time_order,
description=str(self))
else:
recurring_order = Order.objects.create(owner=self.owner,
billing_address=billing_address,
starting_date=when_to_start,
price=self.recurring_price,
recurring_period=recurring_period,
description=str(self))
self.orders.add(recurring_order)
# FIXME: this could/should be part of Order (?)
def create_or_update_recurring_order(self, when_to_start=None, recurring_period=None):
if not self.recurring_price:
return
if not recurring_period:
recurring_period = self.default_recurring_period
if not when_to_start:
when_to_start = timezone.now()
if self.last_recurring_order:
if self.recurring_price < self.last_recurring_order.price:
if when_to_start < self.last_recurring_order.next_cancel_or_downgrade_date:
when_to_start = start_after(self.last_recurring_order.next_cancel_or_downgrade_date)
when_to_end = end_before(when_to_start)
new_order = Order.objects.create(owner=self.owner,
billing_address=self.last_recurring_order.billing_address,
starting_date=when_to_start,
price=self.recurring_price,
recurring_period=recurring_period,
description=str(self),
replaces=self.last_recurring_order)
self.last_recurring_order.replace_with(new_order)
self.orders.add(new_order)
else:
self.create_order(when_to_start, recurring_period)
@property
def recurring_price(self):
""" implement correct values in the child class """
return 0
@property
def one_time_price(self):
""" implement correct values in the child class """
return 0
@property
def is_recurring(self):
return self.recurring_price > 0
@property
def billing_address(self):
return self.order.billing_address
@staticmethod
def allowed_recurring_periods():
return RecurringPeriod.choices
# class Meta:
# abstract = True
def discounted_price_by_period(self, requested_period):
"""
Each product has a standard recurring period for which
we define a pricing. I.e. VPN is usually year, VM is usually monthly.
The user can opt-in to use a different period, which influences the price:
The longer a user commits, the higher the discount.
Products can also be limited in the available periods. For instance
a VPN only makes sense to be bought for at least one day.
Rules are as follows:
given a standard recurring period of ..., changing to ... modifies price ...
# One month for free if buying / year, compared to a month: about 8.33% discount
per_year -> per_month -> /11
per_month -> per_year -> *11
# Month has 30.42 days on average. About 7.9% discount to go monthly
per_month -> per_day -> /28
per_day -> per_month -> *28
# Day has 24h, give one for free
per_day -> per_hour -> /23
per_hour -> per_day -> /23
Examples
VPN @ 120CHF/y becomes
- 10.91 CHF/month (130.91 CHF/year)
- 0.39 CHF/day (142.21 CHF/year)
VM @ 15 CHF/month becomes
- 165 CHF/month (13.75 CHF/month)
- 0.54 CHF/day (16.30 CHF/month)
"""
if self.default_recurring_period == RecurringPeriod.PER_365D:
if requested_period == RecurringPeriod.PER_365D:
return self.recurring_price
if requested_period == RecurringPeriod.PER_30D:
return self.recurring_price/11.
if requested_period == RecurringPeriod.PER_DAY:
return self.recurring_price/11./28.
elif self.default_recurring_period == RecurringPeriod.PER_30D:
if requested_period == RecurringPeriod.PER_365D:
return self.recurring_price*11
if requested_period == RecurringPeriod.PER_30D:
return self.recurring_price
if requested_period == RecurringPeriod.PER_DAY:
return self.recurring_price/28.
elif self.default_recurring_period == RecurringPeriod.PER_DAY:
if requested_period == RecurringPeriod.PER_365D:
return self.recurring_price*11*28
if requested_period == RecurringPeriod.PER_30D:
return self.recurring_price*28
if requested_period == RecurringPeriod.PER_DAY:
return self.recurring_price
else:
# FIXME: use the right type of exception here!
raise Exception("Did not implement the discounter for this case")
def save(self, *args, **kwargs):
try:
ba = BillingAddress.get_address_for(self.owner)
except BillingAddress.DoesNotExist:
raise ValidationError("User does not have a billing address")
if not ba.active:
raise ValidationError("User does not have an active billing address")
super().save(*args, **kwargs)
# Sample products included into uncloud # Sample products included into uncloud
class SampleOneTimeProduct(models.Model): class SampleOneTimeProduct(models.Model):
""" """

View file

@ -0,0 +1,721 @@
from django.test import TestCase
from django.contrib.auth import get_user_model
from datetime import datetime, date, timedelta
from django.utils import timezone
from .models import *
from uncloud_service.models import GenericServiceProduct
class OrderTestCase(TestCase):
"""
The heart of ordering products
"""
def setUp(self):
self.user = get_user_model().objects.create(
username='random_user',
email='jane.random@domain.tld')
self.ba = BillingAddress.objects.create(
owner=self.user,
organization = 'Test org',
street="unknown",
city="unknown",
postal_code="somewhere else",
active=True)
def test_create_one_time_product(self):
"""
One time payment products cannot be updated - can they?
"""
p = SampleOneTimeProduct.objects.create(owner=self.user)
self.assertEqual(p.one_time_price, 5)
self.assertEqual(p.recurring_price, 0)
# class ProductTestCase(TestCase):
# """
# Test products and products <-> order interaction
# """
# def setUp(self):
# self.user = get_user_model().objects.create(
# username='random_user',
# email='jane.random@domain.tld')
# self.ba = BillingAddress.objects.create(
# owner=self.user,
# organization = 'Test org',
# street="unknown",
# city="unknown",
# postal_code="somewhere else",
# active=True)
# def test_create_one_time_product(self):
# """
# One time payment products cannot be updated - can they?
# """
# p = SampleOneTimeProduct.objects.create(owner=self.user)
# self.assertEqual(p.one_time_price, 5)
# self.assertEqual(p.recurring_price, 0)
# def test_create_product_without_active_billing_address(self):
# """
# Fail to create a product without an active billing address
# """
# self.ba.active = False
# self.ba.save()
# with self.assertRaises(ValidationError):
# p = SampleOneTimeProduct.objects.create(owner=self.user)
# def test_create_product_without_billing_address(self):
# """
# Fail to create a product without a billing address
# """
# user2 = get_user_model().objects.create(
# username='random_user2',
# email='jane.randomly@domain.tld')
# with self.assertRaises(ValidationError):
# p = SampleOneTimeProduct.objects.create(owner=user2)
# def test_create_order_creates_correct_order_count(self):
# """
# Ensure creating orders from product only creates 1 order
# """
# # One order
# p = SampleOneTimeProduct.objects.create(owner=self.user)
# p.create_order(timezone.make_aware(datetime.datetime(2020,3,3)))
# order_count = Order.objects.filter(owner=self.user).count()
# self.assertEqual(order_count, 1)
# # One more order
# p = SampleRecurringProduct.objects.create(owner=self.user)
# p.create_order(timezone.make_aware(datetime.datetime(2020,3,3)))
# order_count = Order.objects.filter(owner=self.user).count()
# self.assertEqual(order_count, 2)
# # Should create 2 orders
# p = SampleRecurringProductOneTimeFee.objects.create(owner=self.user)
# p.create_order(timezone.make_aware(datetime.datetime(2020,3,3)))
# order_count = Order.objects.filter(owner=self.user).count()
# self.assertEqual(order_count, 4)
# def test_update_recurring_order(self):
# """
# Ensure creating orders from product only creates 1 order
# """
# p = SampleRecurringProduct.objects.create(owner=self.user)
# p.create_order(timezone.make_aware(datetime.datetime(2020,3,3)))
# p.create_or_update_recurring_order(timezone.make_aware(datetime.datetime(2020,3,4)))
# # FIXME: where is the assert?
class BillingAddressTestCase(TestCase):
def setUp(self):
self.user = get_user_model().objects.create(
username='random_user',
email='jane.random@domain.tld')
def test_user_no_address(self):
"""
Raise an error, when there is no address
"""
self.assertRaises(uncloud_pay.models.BillingAddress.DoesNotExist,
BillingAddress.get_address_for,
self.user)
def test_user_only_inactive_address(self):
"""
Raise an error, when there is no active address
"""
ba = BillingAddress.objects.create(
owner=self.user,
organization = 'Test org',
street="unknown",
city="unknown",
postal_code="somewhere else",
active=False)
self.assertRaises(uncloud_pay.models.BillingAddress.DoesNotExist,
BillingAddress.get_address_for,
self.user)
def test_find_active_address(self):
"""
Find the active address
"""
ba = BillingAddress.objects.create(
owner=self.user,
organization = 'Test org',
street="unknown",
city="unknown",
postal_code="unknown",
active=True)
self.assertEqual(BillingAddress.get_address_for(self.user), ba)
def test_find_right_address_with_multiple_addresses(self):
"""
Find the active address only, skip inactive
"""
ba = BillingAddress.objects.create(
owner=self.user,
organization = 'Test org',
street="unknown",
city="unknown",
postal_code="unknown",
active=True)
ba2 = BillingAddress.objects.create(
owner=self.user,
organization = 'Test org',
street="unknown",
city="unknown",
postal_code="somewhere else",
active=False)
self.assertEqual(BillingAddress.get_address_for(self.user), ba)
def test_change_addresses(self):
"""
Switch the active address
"""
ba = BillingAddress.objects.create(
owner=self.user,
organization = 'Test org',
street="unknown",
city="unknown",
postal_code="unknown",
active=True)
self.assertEqual(BillingAddress.get_address_for(self.user), ba)
ba.active=False
ba.save()
ba2 = BillingAddress.objects.create(
owner=self.user,
organization = 'Test org',
street="unknown",
city="unknown",
postal_code="somewhere else",
active=True)
self.assertEqual(BillingAddress.get_address_for(self.user), ba2)
class BillTestCase(TestCase):
def setUp(self):
self.user_without_address = get_user_model().objects.create(
username='no_home_person',
email='far.away@domain.tld')
self.user = get_user_model().objects.create(
username='jdoe',
email='john.doe@domain.tld')
self.recurring_user = get_user_model().objects.create(
username='recurrent_product_user',
email='jane.doe@domain.tld')
self.user_addr = BillingAddress.objects.create(
owner=self.user,
organization = 'Test org',
street="unknown",
city="unknown",
postal_code="unknown",
active=True)
self.recurring_user_addr = BillingAddress.objects.create(
owner=self.recurring_user,
organization = 'Test org',
street="Somewhere",
city="Else",
postal_code="unknown",
active=True)
self.order_meta = {}
self.order_meta[1] = {
'starting_date': timezone.make_aware(datetime.datetime(2020,3,3)),
'ending_date': timezone.make_aware(datetime.datetime(2020,4,17)),
'price': 15,
'description': 'One chocolate bar'
}
self.one_time_order = Order.objects.create(
owner=self.user,
starting_date=self.order_meta[1]['starting_date'],
ending_date=self.order_meta[1]['ending_date'],
recurring_period=RecurringPeriod.ONE_TIME,
price=self.order_meta[1]['price'],
description=self.order_meta[1]['description'],
billing_address=BillingAddress.get_address_for(self.user))
self.recurring_order = Order.objects.create(
owner=self.recurring_user,
starting_date=timezone.make_aware(datetime.datetime(2020,3,3)),
recurring_period=RecurringPeriod.PER_30D,
price=15,
description="A pretty VM",
billing_address=BillingAddress.get_address_for(self.recurring_user)
)
# used for generating multiple bills
self.bill_dates = [
timezone.make_aware(datetime.datetime(2020,3,31)),
timezone.make_aware(datetime.datetime(2020,4,30)),
timezone.make_aware(datetime.datetime(2020,5,31)),
]
def test_bill_one_time_one_bill_record(self):
"""
Ensure there is only 1 bill record per order
"""
bill = Bill.create_next_bill_for_user_address(self.user_addr)
self.assertEqual(self.one_time_order.billrecord_set.count(), 1)
def test_bill_sum_onetime(self):
"""
Check the bill sum for a single one time order
"""
bill = Bill.create_next_bill_for_user_address(self.user_addr)
self.assertEqual(bill.sum, self.order_meta[1]['price'])
def test_bill_creates_record_for_recurring_order(self):
"""
Ensure there is only 1 bill record per order
"""
bill = Bill.create_next_bill_for_user_address(self.recurring_user_addr)
self.assertEqual(self.recurring_order.billrecord_set.count(), 1)
self.assertEqual(bill.billrecord_set.count(), 1)
def test_new_bill_after_closing(self):
"""
After closing a bill and the user has a recurring product,
the next bill run should create e new bill
"""
for ending_date in self.bill_dates:
b = Bill.create_next_bill_for_user_address(self.recurring_user_addr, ending_date)
b.close()
bill_count = Bill.objects.filter(owner=self.recurring_user).count()
self.assertEqual(len(self.bill_dates), bill_count)
def test_multi_addr_multi_bill(self):
"""
Ensure multiple bills are created if orders exist with different billing addresses
"""
username="lotsofplaces"
multi_addr_user = get_user_model().objects.create(
username=username,
email=f"{username}@example.org")
user_addr1 = BillingAddress.objects.create(
owner=multi_addr_user,
organization = 'Test org',
street="unknown",
city="unknown",
postal_code="unknown",
active=True)
order1 = Order.objects.create(
owner=multi_addr_user,
starting_date=self.order_meta[1]['starting_date'],
ending_date=self.order_meta[1]['ending_date'],
recurring_period=RecurringPeriod.ONE_TIME,
price=self.order_meta[1]['price'],
description=self.order_meta[1]['description'],
billing_address=BillingAddress.get_address_for(self.user))
# Make this address inactive
user_addr1.active = False
user_addr1.save()
user_addr2 = BillingAddress.objects.create(
owner=multi_addr_user,
organization = 'Test2 org',
street="unknown2",
city="unknown2",
postal_code="unknown2",
active=True)
order2 = Order.objects.create(
owner=multi_addr_user,
starting_date=self.order_meta[1]['starting_date'],
ending_date=self.order_meta[1]['ending_date'],
recurring_period=RecurringPeriod.ONE_TIME,
price=self.order_meta[1]['price'],
description=self.order_meta[1]['description'],
billing_address=BillingAddress.get_address_for(self.user))
bills = Bill.create_next_bills_for_user(multi_addr_user)
self.assertEqual(len(bills), 2)
# TO BE IMPLEMENTED -- once orders can be marked as "done" / "inactive" / "not for billing"
# def test_skip_disabled_orders(self):
# """
# Ensure that a bill only considers "active" orders
# """
# self.assertEqual(1, 2)
class ModifyProductTestCase(TestCase):
def setUp(self):
self.user = get_user_model().objects.create(
username='random_user',
email='jane.random@domain.tld')
self.ba = BillingAddress.objects.create(
owner=self.user,
organization = 'Test org',
street="unknown",
city="unknown",
postal_code="somewhere else",
active=True)
def test_no_pro_rata_first_bill(self):
"""
The bill should NOT contain a partial amount -- this is a BILL TEST :-)
"""
price = 5
# Standard 30d recurring product
product = SampleRecurringProduct.objects.create(owner=self.user,
rc_price=price)
starting_date = timezone.make_aware(datetime.datetime(2020,3,3))
ending_date = timezone.make_aware(datetime.datetime(2020,3,31))
time_diff = (ending_date - starting_date).total_seconds()
product.create_order(starting_date)
bills = Bill.create_next_bills_for_user(self.user,
ending_date=ending_date)
# We expect 1 bill for 1 billing address and 1 time frame
self.assertEqual(len(bills), 1)
pro_rata_amount = time_diff / product.default_recurring_period.value
self.assertNotEqual(bills[0].sum, pro_rata_amount * price)
self.assertEqual(bills[0].sum, price)
def test_downgrade_product(self):
"""
Test downgrading behaviour:
We create a recurring product (recurring time: 30 days) and downgrade after 15 days.
We create the bill right AFTER the end of the first order.
Expected result:
- First bill record for 30 days
- Second bill record starting after 30 days
- Bill contains two bill records
"""
user = self.user
starting_price = 10
downgrade_price = 5
starting_date = timezone.make_aware(datetime.datetime(2019,3,3))
first_order_should_end_at = starting_date + datetime.timedelta(days=30)
change1_date = start_after(starting_date + datetime.timedelta(days=15))
bill_ending_date = change1_date + datetime.timedelta(days=1)
product = SampleRecurringProduct.objects.create(owner=user, rc_price=starting_price)
product.create_order(starting_date)
product.rc_price = downgrade_price
product.save()
product.create_or_update_recurring_order(when_to_start=change1_date)
bills = Bill.create_next_bills_for_user(user, ending_date=bill_ending_date)
bill = bills[0]
bill_records = BillRecord.objects.filter(bill=bill)
self.assertEqual(len(bill_records), 2)
self.assertEqual(bill_records[0].starting_date, starting_date)
self.assertEqual(bill_records[0].order.ending_date, first_order_should_end_at)
# self.assertEqual(bill_records[0].ending_date, first_order_should_end_at)
# self.assertEqual(bill_records[0].quantity, 1)
# self.assertEqual(bill_records[1].quantity, 1)
# self.assertEqual(int(bill.sum), 15)
def test_upgrade_product(self):
"""
Test upgrading behaviour
"""
user = self.user
# Create product
starting_date = timezone.make_aware(datetime.datetime(2019,3,3))
starting_price = 10
product = SampleRecurringProduct.objects.create(owner=user, rc_price=starting_price)
product.create_order(starting_date)
change1_date = start_after(starting_date + datetime.timedelta(days=15))
product.rc_price = 20
product.save()
product.create_or_update_recurring_order(when_to_start=change1_date)
bill_ending_date = change1_date + datetime.timedelta(days=1)
bills = Bill.create_next_bills_for_user(user, ending_date=bill_ending_date)
bill = bills[0]
bill_records = BillRecord.objects.filter(bill=bill)
self.assertEqual(len(bill_records), 2)
self.assertEqual(bill_records[0].quantity, .5)
self.assertEqual(bill_records[0].ending_date, end_before(change1_date))
self.assertEqual(bill_records[1].quantity, 1)
self.assertEqual(bill_records[1].starting_date, change1_date)
self.assertEqual(int(bill.sum), 25)
# def test_bill_for_increasing_product(self):
# """
# Modify a product, see one pro rata entry
# """
# # Create product
# starting_date = timezone.make_aware(datetime.datetime(2019,3,3))
# starting_price = 30.5
# product = SampleRecurringProduct.objects.create(owner=self.user,
# rc_price=starting_price)
# product.create_order(starting_date)
# recurring_period = product.default_recurring_period.value
# # First change
# change1_date = timezone.make_aware(datetime.datetime(2019,4,17))
# product.rc_price = 49.5
# product.save()
# product.create_or_update_recurring_order(when_to_start=change1_date)
# # Second change
# change2_date = timezone.make_aware(datetime.datetime(2019,5,8))
# product.rc_price = 56.5
# product.save()
# product.create_or_update_recurring_order(when_to_start=change2_date)
# # Create bill one month after 2nd change
# bill_ending_date = timezone.make_aware(datetime.datetime(2019,6,30))
# bills = Bill.create_next_bills_for_user(self.user,
# ending_date=bill_ending_date)
# # only one bill in this test case
# bill = bills[0]
# expected_amount = starting_price
# d2 = starting_date + recurring_period
# duration2 = change1_date - d2
# expected_amount = 0
# # Expected bill sum & records:
# # 2019-03-03 - 2019-04-02 +30d: 30.5
# # 2019-04-02 - 2019-04-17: +15d: 15.25
# # 2019-04-17 - 2019-05-08: +21d: (21/30) * 49.5
# # 2019-05-08 - 2019-06-07: +30d: 56.5
# # 2019-06-07 - 2019-07-07: +30d: 56.5
# self.assertEqual(bills[0].sum, price)
# # expeted result:
# # 1x 5 chf bill record
# # 1x 5 chf bill record
# # 1x 10 partial bill record
# class NotABillingTC(TestCase):
# #class BillingTestCase(TestCase):
# def setUp(self):
# self.user = get_user_model().objects.create(
# username='jdoe',
# email='john.doe@domain.tld')
# self.billing_address = BillingAddress.objects.create(
# owner=self.user,
# street="unknown",
# city="unknown",
# postal_code="unknown")
# def test_basic_monthly_billing(self):
# one_time_price = 10
# recurring_price = 20
# description = "Test Product 1"
# # Three months: full, full, partial.
# # starting_date = datetime.fromisoformat('2020-03-01')
# starting_date = datetime(2020,3,1)
# ending_date = datetime(2020,5,8)
# # Create order to be billed.
# order = Order.objects.create(
# owner=self.user,
# starting_date=starting_date,
# ending_date=ending_date,
# recurring_period=RecurringPeriod.PER_30D,
# recurring_price=recurring_price,
# one_time_price=one_time_price,
# description=description,
# billing_address=self.billing_address)
# # Generate & check bill for first month: full recurring_price + setup.
# first_month_bills = order.generate_initial_bill()
# self.assertEqual(len(first_month_bills), 1)
# self.assertEqual(first_month_bills[0].amount, one_time_price + recurring_price)
# # Generate & check bill for second month: full recurring_price.
# second_month_bills = Bill.generate_for(2020, 4, self.user)
# self.assertEqual(len(second_month_bills), 1)
# self.assertEqual(second_month_bills[0].amount, recurring_price)
# # Generate & check bill for third and last month: partial recurring_price.
# third_month_bills = Bill.generate_for(2020, 5, self.user)
# self.assertEqual(len(third_month_bills), 1)
# # 31 days in May.
# self.assertEqual(float(third_month_bills[0].amount),
# round(round((7/31), AMOUNT_DECIMALS) * recurring_price, AMOUNT_DECIMALS))
# # Check that running Bill.generate_for() twice does not create duplicates.
# self.assertEqual(len(Bill.generate_for(2020, 3, self.user)), 0)
# def test_basic_yearly_billing(self):
# one_time_price = 10
# recurring_price = 150
# description = "Test Product 1"
# starting_date = datetime.fromisoformat('2020-03-31T08:05:23')
# # Create order to be billed.
# order = Order.objects.create(
# owner=self.user,
# starting_date=starting_date,
# recurring_period=RecurringPeriod.PER_365D,
# recurring_price=recurring_price,
# one_time_price=one_time_price,
# description=description,
# billing_address=self.billing_address)
# # Generate & check bill for first year: recurring_price + setup.
# first_year_bills = order.generate_initial_bill()
# self.assertEqual(len(first_year_bills), 1)
# self.assertEqual(first_year_bills[0].starting_date.date(),
# date.fromisoformat('2020-03-31'))
# self.assertEqual(first_year_bills[0].ending_date.date(),
# date.fromisoformat('2021-03-30'))
# self.assertEqual(first_year_bills[0].amount,
# recurring_price + one_time_price)
# # Generate & check bill for second year: recurring_price.
# second_year_bills = Bill.generate_for(2021, 3, self.user)
# self.assertEqual(len(second_year_bills), 1)
# self.assertEqual(second_year_bills[0].starting_date.date(),
# date.fromisoformat('2021-03-31'))
# self.assertEqual(second_year_bills[0].ending_date.date(),
# date.fromisoformat('2022-03-30'))
# self.assertEqual(second_year_bills[0].amount, recurring_price)
# # Check that running Bill.generate_for() twice does not create duplicates.
# self.assertEqual(len(Bill.generate_for(2020, 3, self.user)), 0)
# self.assertEqual(len(Bill.generate_for(2020, 4, self.user)), 0)
# self.assertEqual(len(Bill.generate_for(2020, 2, self.user)), 0)
# self.assertEqual(len(Bill.generate_for(2021, 3, self.user)), 0)
# def test_basic_hourly_billing(self):
# one_time_price = 10
# recurring_price = 1.4
# description = "Test Product 1"
# starting_date = datetime.fromisoformat('2020-03-31T08:05:23')
# ending_date = datetime.fromisoformat('2020-04-01T11:13:32')
# # Create order to be billed.
# order = Order.objects.create(
# owner=self.user,
# starting_date=starting_date,
# ending_date=ending_date,
# recurring_period=RecurringPeriod.PER_HOUR,
# recurring_price=recurring_price,
# one_time_price=one_time_price,
# description=description,
# billing_address=self.billing_address)
# # Generate & check bill for first month: recurring_price + setup.
# first_month_bills = order.generate_initial_bill()
# self.assertEqual(len(first_month_bills), 1)
# self.assertEqual(float(first_month_bills[0].amount),
# round(16 * recurring_price, AMOUNT_DECIMALS) + one_time_price)
# # Generate & check bill for first month: recurring_price.
# second_month_bills = Bill.generate_for(2020, 4, self.user)
# self.assertEqual(len(second_month_bills), 1)
# self.assertEqual(float(second_month_bills[0].amount),
# round(12 * recurring_price, AMOUNT_DECIMALS))

File diff suppressed because it is too large Load diff