Make recurring period a database model

- For easier handling (foreignkeys, many2many)
- For higher flexibility (users can define their own periods)
This commit is contained in:
Nico Schottelius 2020-10-06 15:46:22 +02:00
parent 58883765d7
commit 992c7c551e
11 changed files with 588 additions and 362 deletions

View file

@ -0,0 +1,11 @@
from django.core.management.base import BaseCommand
from uncloud_pay.models import RecurringPeriod
class Command(BaseCommand):
help = 'Add standard uncloud values'
def add_arguments(self, parser):
pass
def handle(self, *args, **options):
RecurringPeriod.populate_db_defaults()

View file

@ -173,7 +173,7 @@ class VPNNetwork(models.Model):
wireguard_public_key = models.CharField(max_length=48) wireguard_public_key = models.CharField(max_length=48)
default_recurring_period = RecurringPeriod.PER_365D # default_recurring_period = RecurringPeriod.PER_365D
@property @property
def recurring_price(self): def recurring_price(self):

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, Product from uncloud_pay.models import Bill, Order, BillRecord, BillingAddress, Product, RecurringPeriod
class BillRecordInline(admin.TabularInline): class BillRecordInline(admin.TabularInline):
@ -90,9 +90,8 @@ admin.site.register(Bill, BillAdmin)
admin.site.register(Order) admin.site.register(Order)
admin.site.register(BillRecord) admin.site.register(BillRecord)
admin.site.register(BillingAddress) admin.site.register(BillingAddress)
admin.site.register(RecurringPeriod)
#for m in [ SampleOneTimeProduct, SampleRecurringProduct, SampleRecurringProductOneTimeFee ]:
admin.site.register(Product) admin.site.register(Product)
#admin.site.register(Order, OrderAdmin) #admin.site.register(Order, OrderAdmin)
#for m in [ SampleOneTimeProduct, SampleRecurringProduct, SampleRecurringProductOneTimeFee ]:

View file

@ -0,0 +1,41 @@
# Generated by Django 3.1 on 2020-10-06 13:19
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('uncloud_pay', '0026_order_should_be_billed'),
]
operations = [
migrations.CreateModel(
name='RecurringPeriod',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100, unique=True)),
('duration_seconds', models.IntegerField(unique=True)),
],
),
migrations.DeleteModel(
name='SampleOneTimeProduct',
),
migrations.DeleteModel(
name='SampleRecurringProduct',
),
migrations.DeleteModel(
name='SampleRecurringProductOneTimeFee',
),
migrations.AlterField(
model_name='order',
name='recurring_period',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.recurringperiod'),
),
migrations.AlterField(
model_name='product',
name='default_recurring_period',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.recurringperiod'),
),
]

View file

@ -64,21 +64,6 @@ def start_after(a_date):
def default_payment_delay(): def default_payment_delay():
return timezone.now() + BILL_PAYMENT_DELAY return timezone.now() + BILL_PAYMENT_DELAY
# See https://docs.djangoproject.com/en/dev/ref/models/fields/#field-choices-enum-types
class RecurringPeriod(models.IntegerChoices):
"""
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.
"""
PER_365D = 365*24*3600, _('Per 365 days')
PER_30D = 30*24*3600, _('Per 30 days')
PER_WEEK = 7*24*3600, _('Per Week')
PER_DAY = 24*3600, _('Per Day')
PER_HOUR = 3600, _('Per Hour')
PER_MINUTE = 60, _('Per Minute')
PER_SECOND = 1, _('Per Second')
ONE_TIME = 0, _('Onetime')
class Currency(models.TextChoices): class Currency(models.TextChoices):
""" """
@ -236,6 +221,41 @@ class PaymentMethod(models.Model):
# non-primary method. # non-primary method.
pass pass
# See https://docs.djangoproject.com/en/dev/ref/models/fields/#field-choices-enum-types
class RecurringPeriodChoices(models.IntegerChoices):
"""
This is an old class and being superseeded by the database model below
"""
PER_365D = 365*24*3600, _('Per 365 days')
PER_30D = 30*24*3600, _('Per 30 days')
PER_WEEK = 7*24*3600, _('Per Week')
PER_DAY = 24*3600, _('Per Day')
PER_HOUR = 3600, _('Per Hour')
PER_MINUTE = 60, _('Per Minute')
PER_SECOND = 1, _('Per Second')
ONE_TIME = 0, _('Onetime')
# RecurringPeriods
class RecurringPeriod(models.Model):
"""
Available recurring periods.
By default seeded from RecurringPeriodChoices
"""
name = models.CharField(max_length=100, unique=True)
duration_seconds = models.IntegerField(unique=True)
@classmethod
def populate_db_defaults(cls):
for (seconds, name) in RecurringPeriodChoices.choices:
obj, created = cls.objects.get_or_create(name=name,
defaults={ 'duration_seconds': seconds })
def __str__(self):
return f"{self.name} ({self.duration_seconds})"
### ###
# Bills. # Bills.
@ -313,7 +333,11 @@ class Product(UncloudModel):
description = models.CharField(max_length=1024) description = models.CharField(max_length=1024)
config = models.JSONField() config = models.JSONField()
default_recurring_period = models.IntegerField(choices=RecurringPeriod.choices, default=RecurringPeriod.PER_30D)
# default_recurring_period = models.IntegerField(choices=RecurringPeriod.choices, default=RecurringPeriod.PER_30D)
default_recurring_period = models.ForeignKey(RecurringPeriod, on_delete=models.CASCADE, editable=True)
currency = models.CharField(max_length=32, choices=Currency.choices, default=Currency.CHF) currency = models.CharField(max_length=32, choices=Currency.choices, default=Currency.CHF)
@property @property
@ -409,7 +433,6 @@ class Product(UncloudModel):
self.create_order(when_to_start, recurring_period) self.create_order(when_to_start, recurring_period)
@property @property
def recurring_price(self): def recurring_price(self):
""" implement correct values in the child class """ """ implement correct values in the child class """
@ -564,8 +587,9 @@ class Order(models.Model):
ending_date = models.DateTimeField(blank=True, null=True) ending_date = models.DateTimeField(blank=True, null=True)
# FIXME: ensure the period is defined in the product # FIXME: ensure the period is defined in the product
recurring_period = models.IntegerField(choices = RecurringPeriod.choices, # recurring_period = models.IntegerField(choices = RecurringPeriod.choices,
default = RecurringPeriod.PER_30D) # default = RecurringPeriod.PER_30D)
recurring_period = models.ForeignKey(RecurringPeriod, on_delete=models.CASCADE, editable=True)
one_time_price = models.DecimalField(default=0.0, one_time_price = models.DecimalField(default=0.0,
max_digits=AMOUNT_MAX_DIGITS, max_digits=AMOUNT_MAX_DIGITS,
@ -591,7 +615,6 @@ class Order(models.Model):
blank=True, blank=True,
null=True) null=True)
should_be_billed = models.BooleanField(default=True) should_be_billed = models.BooleanField(default=True)
@property @property
@ -700,6 +723,29 @@ class Order(models.Model):
self.ending_date = end_before(new_order.starting_date) self.ending_date = end_before(new_order.starting_date)
self.save() self.save()
def update_order(self, config, starting_date=None):
"""
Updating an order means creating a new order and reference the previous order
"""
if not starting_date:
starting_date = timezone.now()
new_order = self.__class__(owner=self.owner,
billing_address=self.billing_address,
product=self.product,
starting_date=starting_date,
config=config)
(new_order_one_time_price, new_order_recurring_price) = new_order.prices
new_order.replaces = self
new_order.save()
self.ending_date = end_before(new_order.starting_date)
self.save()
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
if self.ending_date and self.ending_date < self.starting_date: if self.ending_date and self.ending_date < self.starting_date:
raise ValidationError("End date cannot be before starting date") raise ValidationError("End date cannot be before starting date")
@ -778,12 +824,14 @@ class Order(models.Model):
is_recurring_record=True) is_recurring_record=True)
def save(self, *args, **kwargs): @property
def prices(self):
one_time_price = 0 one_time_price = 0
recurring_price = 0 recurring_price = 0
# FIXME: support amount independent one time prices # FIXME: support amount independent one time prices
# FIXME: support a base price # FIXME: support a base price
# FIXME: adjust to the selected recurring_period
if 'features' in self.product.config: if 'features' in self.product.config:
for feature in self.product.config['features']: for feature in self.product.config['features']:
@ -794,12 +842,14 @@ class Order(models.Model):
one_time_price += self.product.config['features'][feature]['one_time_price'] * self.config['features'][feature] one_time_price += self.product.config['features'][feature]['one_time_price'] * self.config['features'][feature]
recurring_price += self.product.config['features'][feature]['recurring_price'] * self.config['features'][feature] recurring_price += self.product.config['features'][feature]['recurring_price'] * self.config['features'][feature]
return (one_time_price, recurring_price)
def save(self, *args, **kwargs):
# Calculate the price of the order when we create it
# IMMUTABLE fields -- need to create new order to modify them # IMMUTABLE fields -- need to create new order to modify them
# However this is not enforced here... # However this is not enforced here...
if self._state.adding: if self._state.adding:
self.one_time_price = one_time_price (self.one_time_price, self.recurring_price) = self.prices
self.recurring_price = recurring_price
super().save(*args, **kwargs) super().save(*args, **kwargs)
@ -1137,50 +1187,50 @@ class BillRecord(models.Model):
super().save(*args, **kwargs) super().save(*args, **kwargs)
# Sample products included into uncloud # # Sample products included into uncloud
class SampleOneTimeProduct(models.Model): # class SampleOneTimeProduct(models.Model):
""" # """
Products are usually more complex, but this product shows how easy # Products are usually more complex, but this product shows how easy
it can be to create your own one time product. # it can be to create your own one time product.
""" # """
default_recurring_period = RecurringPeriod.ONE_TIME # default_recurring_period = RecurringPeriod.ONE_TIME
ot_price = models.IntegerField(default=5) # ot_price = models.IntegerField(default=5)
@property # @property
def one_time_price(self): # def one_time_price(self):
return self.ot_price # return self.ot_price
class SampleRecurringProduct(models.Model): # class SampleRecurringProduct(models.Model):
""" # """
Products are usually more complex, but this product shows how easy # Products are usually more complex, but this product shows how easy
it can be to create your own recurring fee product. # it can be to create your own recurring fee product.
""" # """
default_recurring_period = RecurringPeriod.PER_30D # default_recurring_period = RecurringPeriod.PER_30D
rc_price = models.IntegerField(default=10) # rc_price = models.IntegerField(default=10)
@property # @property
def recurring_price(self): # def recurring_price(self):
return self.rc_price # return self.rc_price
class SampleRecurringProductOneTimeFee(models.Model): # class SampleRecurringProductOneTimeFee(models.Model):
""" # """
Products are usually more complex, but this product shows how easy # Products are usually more complex, but this product shows how easy
it can be to create your own one time + recurring fee product. # it can be to create your own one time + recurring fee product.
""" # """
default_recurring_period = RecurringPeriod.PER_30D # default_recurring_period = RecurringPeriod.PER_30D
ot_price = models.IntegerField(default=5) # ot_price = models.IntegerField(default=5)
rc_price = models.IntegerField(default=10) # rc_price = models.IntegerField(default=10)
@property # @property
def one_time_price(self): # def one_time_price(self):
return self.ot_price # return self.ot_price
@property # @property
def recurring_price(self): # def recurring_price(self):
return self.rc_price # return self.rc_price

View file

@ -82,7 +82,7 @@ class BillRecordSerializer(serializers.Serializer):
description = serializers.CharField() description = serializers.CharField()
one_time_price = serializers.DecimalField(AMOUNT_MAX_DIGITS, AMOUNT_DECIMALS) one_time_price = serializers.DecimalField(AMOUNT_MAX_DIGITS, AMOUNT_DECIMALS)
recurring_price = serializers.DecimalField(AMOUNT_MAX_DIGITS, AMOUNT_DECIMALS) recurring_price = serializers.DecimalField(AMOUNT_MAX_DIGITS, AMOUNT_DECIMALS)
recurring_period = serializers.ChoiceField(choices=RecurringPeriod.choices) # recurring_period = serializers.ChoiceField()
recurring_count = serializers.DecimalField(AMOUNT_MAX_DIGITS, AMOUNT_DECIMALS) recurring_count = serializers.DecimalField(AMOUNT_MAX_DIGITS, AMOUNT_DECIMALS)
vat_rate = serializers.DecimalField(AMOUNT_MAX_DIGITS, AMOUNT_DECIMALS) vat_rate = serializers.DecimalField(AMOUNT_MAX_DIGITS, AMOUNT_DECIMALS)
vat_amount = serializers.DecimalField(AMOUNT_MAX_DIGITS, AMOUNT_DECIMALS) vat_amount = serializers.DecimalField(AMOUNT_MAX_DIGITS, AMOUNT_DECIMALS)

View file

@ -27,7 +27,7 @@ chocolate_order_config = {
chocolate_one_time_price = chocolate_order_config['features']['gramm'] * chocolate_product_config['features']['gramm']['one_time_price'] chocolate_one_time_price = chocolate_order_config['features']['gramm'] * chocolate_product_config['features']['gramm']['one_time_price']
vm_sample_product_config = { vm_product_config = {
'features': { 'features': {
'cores': 'cores':
{ 'min': 1, { 'min': 1,
@ -44,13 +44,28 @@ vm_sample_product_config = {
}, },
} }
vm_sample_order_config = { vm_order_config = {
'features': { 'features': {
'cores': 2, 'cores': 2,
'ram_gb': 2 'ram_gb': 2
} }
} }
vm_order_downgrade_config = {
'features': {
'cores': 1,
'ram_gb': 1
}
}
vm_order_upgrade_config = {
'features': {
'cores': 4,
'ram_gb': 4
}
}
class ProductTestCase(TestCase): class ProductTestCase(TestCase):
""" """
@ -77,7 +92,7 @@ class ProductTestCase(TestCase):
p = Product.objects.create(name="Testproduct", p = Product.objects.create(name="Testproduct",
description="Only for testing", description="Only for testing",
config=vm_sample_product_config) config=vm_product_config)
class OrderTestCase(TestCase): class OrderTestCase(TestCase):
@ -105,16 +120,287 @@ class OrderTestCase(TestCase):
p = Product.objects.create(name="Testproduct", p = Product.objects.create(name="Testproduct",
description="Only for testing", description="Only for testing",
config=vm_sample_product_config) config=vm_product_config)
o = Order.objects.create(owner=self.user, o = Order.objects.create(owner=self.user,
billing_address=self.ba, billing_address=self.ba,
product=p, product=p,
config=vm_sample_order_config) config=vm_order_config)
self.assertEqual(o.one_time_price, 0) self.assertEqual(o.one_time_price, 0)
self.assertEqual(o.recurring_price, 16) self.assertEqual(o.recurring_price, 16)
def test_change_order(self):
"""
Change an order and ensure that
- a new order is created
- the price is correct in the new order
"""
p = Product.objects.create(name="Testproduct",
description="Only for testing",
config=vm_product_config)
order1 = Order.objects.create(owner=self.user,
billing_address=self.ba,
product=p,
config=vm_order_config)
self.assertEqual(order1.one_time_price, 0)
self.assertEqual(order1.recurring_price, 16)
class ModifyOrderTestCase(TestCase):
"""
Test typical order flows like
- cancelling
- downgrading
- upgrading
"""
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)
self.product = Product.objects.create(name="Testproduct",
description="Only for testing",
config=vm_product_config)
def test_change_order(self):
"""
Test changing an order
Expected result:
- Old order should be closed before new order starts
- New order should start at starting data
"""
user = self.user
starting_price = 16
downgrade_price = 8
starting_date = timezone.make_aware(datetime.datetime(2019,3,3))
ending1_date = starting_date + datetime.timedelta(days=15)
change1_date = start_after(ending1_date)
bill_ending_date = change1_date + datetime.timedelta(days=1)
order1 = Order.objects.create(owner=self.user,
billing_address=BillingAddress.get_address_for(self.user),
product=self.product,
config=vm_order_config,
starting_date=starting_date)
order1.update_order(vm_order_downgrade_config, starting_date=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].ending_date, ending1_date)
self.assertEqual(bill_records[1].starting_date, change1_date)
# 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 = 16
downgrade_price = 8
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)
order1 = Order.objects.create(owner=self.user,
billing_address=BillingAddress.get_address_for(self.user),
product=self.product,
config=vm_order_config,
starting_date=starting_date)
order1.update_order(vm_order_downgrade_config, starting_date=change1_date)
# product = Product.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 BillTestCase(TestCase): class BillTestCase(TestCase):
@ -163,14 +449,11 @@ class BillTestCase(TestCase):
description="Not only for testing, but for joy", description="Not only for testing, but for joy",
config=chocolate_product_config) config=chocolate_product_config)
# self.recurring_order = Order.objects.create(
# owner=self.recurring_user, self.vm = Product.objects.create(name="Super Fast VM",
# starting_date=timezone.make_aware(datetime.datetime(2020,3,3)), description="Zooooom",
# recurring_period=RecurringPeriod.PER_30D, config=vm_product_config)
# price=15,
# description="A pretty VM",
# billing_address=BillingAddress.get_address_for(self.recurring_user)
# )
# used for generating multiple bills # used for generating multiple bills
self.bill_dates = [ self.bill_dates = [
@ -190,6 +473,28 @@ class BillTestCase(TestCase):
ending_date=self.order_meta[1]['ending_date'], ending_date=self.order_meta[1]['ending_date'],
config=chocolate_order_config) config=chocolate_order_config)
def order_vm(self, owner=None):
if not owner:
owner = self.recurring_user
return Order.objects.create(
owner=owner,
product=self.vm,
config=vm_order_config,
billing_address=BillingAddress.get_address_for(self.recurring_user),
starting_date=timezone.make_aware(datetime.datetime(2020,3,3)),
)
return Order.objects.create(
owner=self.user,
recurring_period=RecurringPeriod.ONE_TIME,
product=self.chocolate,
billing_address=BillingAddress.get_address_for(self.user),
starting_date=self.order_meta[1]['starting_date'],
ending_date=self.order_meta[1]['ending_date'],
config=chocolate_order_config)
def test_bill_one_time_one_bill_record(self): def test_bill_one_time_one_bill_record(self):
@ -213,83 +518,95 @@ class BillTestCase(TestCase):
self.assertEqual(bill.sum, chocolate_one_time_price) self.assertEqual(bill.sum, chocolate_one_time_price)
# def test_bill_creates_record_for_recurring_order(self): def test_bill_creates_record_for_recurring_order(self):
# """ """
# Ensure there is only 1 bill record per order Ensure there is only 1 bill record per order
# """ """
# bill = Bill.create_next_bill_for_user_address(self.recurring_user_addr) order = self.order_vm()
bill = Bill.create_next_bill_for_user_address(self.recurring_user_addr)
# self.assertEqual(self.recurring_order.billrecord_set.count(), 1) self.assertEqual(order.billrecord_set.count(), 1)
# self.assertEqual(bill.billrecord_set.count(), 1) self.assertEqual(bill.billrecord_set.count(), 1)
# def test_new_bill_after_closing(self): def test_new_bill_after_closing(self):
# """ """
# After closing a bill and the user has a recurring product, After closing a bill and the user has a recurring product,
# the next bill run should create e new bill the next bill run should create e new bill
# """ """
# for ending_date in self.bill_dates: order = self.order_vm()
# 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() for ending_date in self.bill_dates:
b = Bill.create_next_bill_for_user_address(self.recurring_user_addr, ending_date)
b.close()
# self.assertEqual(len(self.bill_dates), bill_count) bill_count = Bill.objects.filter(owner=self.recurring_user).count()
# def test_multi_addr_multi_bill(self): self.assertEqual(len(self.bill_dates), bill_count)
# """
# Ensure multiple bills are created if orders exist with different billing addresses
# """
# username="lotsofplaces" # def test_multi_addr_multi_bill(self):
# multi_addr_user = get_user_model().objects.create( # """
# username=username, # Ensure multiple bills are created if orders exist with different billing addresses
# email=f"{username}@example.org") # """
# user_addr1 = BillingAddress.objects.create( # username="lotsofplaces"
# owner=multi_addr_user, # multi_addr_user = get_user_model().objects.create(
# organization = 'Test org', # username=username,
# street="unknown", # email=f"{username}@example.org")
# city="unknown",
# postal_code="unknown",
# active=True)
# order1 = Order.objects.create( # user_addr1 = BillingAddress.objects.create(
# owner=multi_addr_user, # owner=multi_addr_user,
# starting_date=self.order_meta[1]['starting_date'], # organization = 'Test org',
# ending_date=self.order_meta[1]['ending_date'], # street="unknown",
# recurring_period=RecurringPeriod.ONE_TIME, # city="unknown",
# price=self.order_meta[1]['price'], # postal_code="unknown",
# description=self.order_meta[1]['description'], # active=True)
# billing_address=BillingAddress.get_address_for(self.user))
# # Make this address inactive # order1 = Order.objects.create(
# user_addr1.active = False # owner=multi_addr_user,
# user_addr1.save() # recurring_period=RecurringPeriod.ONE_TIME,
# product=self.chocolate,
# billing_address=BillingAddress.get_address_for(self.user),
# starting_date=self.order_meta[1]['starting_date'],
# ending_date=self.order_meta[1]['ending_date'],
# config=chocolate_order_config)
# user_addr2 = BillingAddress.objects.create( # order1 = Order.objects.create(
# owner=multi_addr_user, # owner=multi_addr_user,
# organization = 'Test2 org', # starting_date=self.order_meta[1]['starting_date'],
# street="unknown2", # ending_date=self.order_meta[1]['ending_date'],
# city="unknown2", # recurring_period=RecurringPeriod.ONE_TIME,
# postal_code="unknown2", # price=self.order_meta[1]['price'],
# active=True) # description=self.order_meta[1]['description'],
# billing_address=BillingAddress.get_address_for(self.user))
# order2 = Order.objects.create( # # Make this address inactive
# owner=multi_addr_user, # user_addr1.active = False
# starting_date=self.order_meta[1]['starting_date'], # user_addr1.save()
# ending_date=self.order_meta[1]['ending_date'],
# recurring_period=RecurringPeriod.ONE_TIME, # user_addr2 = BillingAddress.objects.create(
# price=self.order_meta[1]['price'], # owner=multi_addr_user,
# description=self.order_meta[1]['description'], # organization = 'Test2 org',
# billing_address=BillingAddress.get_address_for(self.user)) # 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) # bills = Bill.create_next_bills_for_user(multi_addr_user)
# self.assertEqual(len(bills), 2) # self.assertEqual(len(bills), 2)
# # TO BE IMPLEMENTED -- once orders can be marked as "done" / "inactive" / "not for billing" # # TO BE IMPLEMENTED -- once orders can be marked as "done" / "inactive" / "not for billing"
@ -392,21 +709,21 @@ class BillTestCase(TestCase):
# # FIXME: where is the assert? # # FIXME: where is the assert?
# class BillingAddressTestCase(TestCase): class BillingAddressTestCase(TestCase):
# def setUp(self): def setUp(self):
# self.user = get_user_model().objects.create( self.user = get_user_model().objects.create(
# username='random_user', username='random_user',
# email='jane.random@domain.tld') email='jane.random@domain.tld')
# def test_user_no_address(self): def test_user_no_address(self):
# """ """
# Raise an error, when there is no address Raise an error, when there is no address
# """ """
# self.assertRaises(uncloud_pay.models.BillingAddress.DoesNotExist, self.assertRaises(uncloud_pay.models.BillingAddress.DoesNotExist,
# BillingAddress.get_address_for, BillingAddress.get_address_for,
# self.user) self.user)
# def test_user_only_inactive_address(self): # def test_user_only_inactive_address(self):
# """ # """
@ -497,198 +814,6 @@ class BillTestCase(TestCase):
# 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 NotABillingTC(TestCase):
# #class BillingTestCase(TestCase): # #class BillingTestCase(TestCase):

View file

@ -15,8 +15,8 @@ class MatrixServiceProduct(models.Model):
domain = models.CharField(max_length=255, default='domain.tld') domain = models.CharField(max_length=255, default='domain.tld')
# Default recurring price is PER_MONT, see Product class. # Default recurring price is PER_MONT, see Product class.
def recurring_price(self, recurring_period=RecurringPeriod.PER_30D): # def recurring_price(self, recurring_period=RecurringPeriod.PER_30D):
return self.monthly_managment_fee # return self.monthly_managment_fee
@staticmethod @staticmethod
def base_image(): def base_image():
@ -24,11 +24,11 @@ class MatrixServiceProduct(models.Model):
#e return VMDiskImageProduct.objects.get(uuid="93e564c5-adb3-4741-941f-718f76075f02") #e return VMDiskImageProduct.objects.get(uuid="93e564c5-adb3-4741-941f-718f76075f02")
return False return False
@staticmethod # @staticmethod
def allowed_recurring_periods(): # def allowed_recurring_periods():
return list(filter( # return list(filter(
lambda pair: pair[0] in [RecurringPeriod.PER_30D], # lambda pair: pair[0] in [RecurringPeriod.PER_30D],
RecurringPeriod.choices)) # RecurringPeriod.choices))
@property @property
def one_time_price(self): def one_time_price(self):

View file

@ -17,8 +17,8 @@ class MatrixServiceProductSerializer(serializers.ModelSerializer):
read_only_fields = ['order', 'owner', 'status'] read_only_fields = ['order', 'owner', 'status']
class OrderMatrixServiceProductSerializer(MatrixServiceProductSerializer): class OrderMatrixServiceProductSerializer(MatrixServiceProductSerializer):
recurring_period = serializers.ChoiceField( # recurring_period = serializers.ChoiceField(
choices=MatrixServiceProduct.allowed_recurring_periods()) # choices=MatrixServiceProduct.allowed_recurring_periods())
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super(OrderMatrixServiceProductSerializer, self).__init__(*args, **kwargs) super(OrderMatrixServiceProductSerializer, self).__init__(*args, **kwargs)
@ -42,8 +42,8 @@ class GenericServiceProductSerializer(serializers.ModelSerializer):
read_only_fields = [ 'owner', 'status'] read_only_fields = [ 'owner', 'status']
class OrderGenericServiceProductSerializer(GenericServiceProductSerializer): class OrderGenericServiceProductSerializer(GenericServiceProductSerializer):
recurring_period = serializers.ChoiceField( # recurring_period = serializers.ChoiceField(
choices=GenericServiceProduct.allowed_recurring_periods()) # choices=GenericServiceProduct.allowed_recurring_periods())
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super(OrderGenericServiceProductSerializer, self).__init__(*args, **kwargs) super(OrderGenericServiceProductSerializer, self).__init__(*args, **kwargs)

View file

@ -71,12 +71,12 @@ class VMProduct(models.Model):
return "Virtual machine '{}': {} core(s), {}GB memory".format( return "Virtual machine '{}': {} core(s), {}GB memory".format(
self.name, self.cores, self.ram_in_gb) self.name, self.cores, self.ram_in_gb)
@staticmethod # @staticmethod
def allowed_recurring_periods(): # def allowed_recurring_periods():
return list(filter( # return list(filter(
lambda pair: pair[0] in [RecurringPeriod.PER_365D, # lambda pair: pair[0] in [RecurringPeriod.PER_365D,
RecurringPeriod.PER_30D, RecurringPeriod.PER_HOUR], # RecurringPeriod.PER_30D, RecurringPeriod.PER_HOUR],
RecurringPeriod.choices)) # RecurringPeriod.choices))
def __str__(self): def __str__(self):

View file

@ -101,8 +101,8 @@ class VMProductSerializer(serializers.ModelSerializer):
read_only_fields = ['order', 'owner', 'status'] read_only_fields = ['order', 'owner', 'status']
class OrderVMProductSerializer(VMProductSerializer): class OrderVMProductSerializer(VMProductSerializer):
recurring_period = serializers.ChoiceField( # recurring_period = serializers.ChoiceField(
choices=VMWithOSProduct.allowed_recurring_periods()) # choices=VMWithOSProduct.allowed_recurring_periods())
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super(VMProductSerializer, self).__init__(*args, **kwargs) super(VMProductSerializer, self).__init__(*args, **kwargs)
@ -133,8 +133,8 @@ class DCLVMProductSerializer(serializers.HyperlinkedModelSerializer):
""" """
# Custom field used at creation (= ordering) only. # Custom field used at creation (= ordering) only.
recurring_period = serializers.ChoiceField( # recurring_period = serializers.ChoiceField(
choices=VMProduct.allowed_recurring_periods()) # choices=VMProduct.allowed_recurring_periods())
os_disk_uuid = serializers.UUIDField() os_disk_uuid = serializers.UUIDField()
# os_disk_size = # os_disk_size =