remove big mistake: orders from product

Signed-off-by: Nico Schottelius <nico@nico-notebook.schottelius.org>
This commit is contained in:
Nico Schottelius 2020-09-28 20:44:50 +02:00
parent d8a7964fed
commit 1aead50170
8 changed files with 287 additions and 35 deletions

View file

@ -84,6 +84,14 @@ python manage.py migrate
* URLs * URLs
- api/ - the rest API - api/ - the rest API
* uncloud Products * uncloud Products
** Product features
- Dependencies on other products
- Minimum parameters (min cpu, min ram, etc).
- Can also realise the dcl vm
- dualstack vm = VM + IPv4 + SSD
- Need to have a non-misguiding name for the "bare VM"
- Should support network boot (?)
** VPN ** VPN
*** How to add a new VPN Host *** How to add a new VPN Host
**** Install wireguard to the host **** Install wireguard to the host
@ -219,7 +227,85 @@ VPNNetworks can be managed by all authenticated users.
- Product - Product
***** TODO Recurring product support ***** TODO Recurring product support
****** TODO Support replacing orders for updates ****** TODO Support replacing orders for updates
****** TODO [#A] Finish split of bill creation ****** DONE [#A] Finish split of bill creation
****** TODO [#A] Test the new functions in the Order class CLOSED: [2020-09-11 Fri 23:19]
****** TODO Test the new functions in the Order class
****** Define the correct order replacement logic
Assumption:
- recurringperiods are 30days
******* Case 1: downgrading
- User commits to 10 CHF for 30 days
- Wants to downgrade after 15 days to 5 CHF product
- Expected result:
- order 1: 10 CHF until +30days
- order 2: 5 CHF starting 30days + 1s
- Sum of the two orders is 15 CHF
- Question is
- when is the VM shutdown?
- a) instantly
- b) at the end of the cycle
- best solution
- user can choose between a ... b any time
******* Duration
- You cannot cancel the duration
- You can upgrade and with that cancel the duration
- The idea of a duration is that you commit for it
- If you want to commit lower (daily basis for instance) you
have higher per period prices
******* Case X
- User has VM with 2 Core / 2 GB RAM
- User modifies with to 1 core / 3 GB RAM
- We treat it as down/upgrade independent of the modifications
******* Case 2: upgrading after 1 day
- committed for 30 days
- upgrade after 1 day
- so first order will be charged for 1/30ths
******* Case 2: upgrading
- User commits to 10 CHF for 30 days
- Wants to upgrade after 15 days to 20 CHF product
- Order 1 : 1 VM with 2 Core / 2 GB / 10 SSD -- 10 CHF
- 30days period, stopped after 15, so quantity is 0.5 = 5 CHF
- Order 2 : 1 VM with 2 Core / 6 GB / 10 SSD -- 20 CHF
- after 15 days
- VM is upgraded instantly
- Expected result:
- order 1: 10 CHF until +15days = 0.5 units = 5 CHF
- order 2: 20 CHF starting 15days + 1s ... +30 days after
the 15 days -> 45 days = 1 unit = 20 CHF
- Total on bill: 25 CHF
******* Case 2: upgrading
- User commits to 10 CHF for 30 days
- Wants to upgrade after 15 days to 20 CHF product
- Expected result:
- order 1: 10 CHF until +30days = 1 units = 10 CHF
- order 2: 20 CHF starting 15days + 1s = 1 unit = 20 CHF
- Total on bill: 30 CHF
****** TODO Note: ending date not set if replaced by default (implicit!)
- Should the new order modify the old order on save()?
****** DONE Fix totally wrong bill dates in our test case
CLOSED: [2020-09-09 Wed 01:00]
- 2020 used instead of 2019
- Was due to existing test data ...
***** TODO Bill logic is still wrong
- Bill starting_date is the date of the first order
- However first encountered order does not have to be the
earliest in the bill!
- Bills should not have a duration
- Bills should only have a (unique) issue date
- We charge based on bill_records
- Last time charged issue date of the bill OR earliest date
after that
- Every bill generation checks all (relevant) orders
- add a flag "not_for_billing" or "closed"
- query on that flag
- verify it every time
***** TODO Generating bill for admins/staff ***** TODO Generating bill for admins/staff
- -

View file

@ -0,0 +1,17 @@
# Generated by Django 3.1 on 2020-09-28 18:44
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('opennebula', '0004_auto_20200809_1237'),
]
operations = [
migrations.RemoveField(
model_name='vm',
name='orders',
),
]

View file

@ -0,0 +1,17 @@
# Generated by Django 3.1 on 2020-09-28 18:44
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('uncloud_net', '0004_auto_20200809_1237'),
]
operations = [
migrations.RemoveField(
model_name='vpnnetwork',
name='orders',
),
]

View file

@ -0,0 +1,25 @@
# Generated by Django 3.1 on 2020-09-28 18:44
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('uncloud_pay', '0014_auto_20200825_1915'),
]
operations = [
migrations.RemoveField(
model_name='sampleonetimeproduct',
name='orders',
),
migrations.RemoveField(
model_name='samplerecurringproduct',
name='orders',
),
migrations.RemoveField(
model_name='samplerecurringproductonetimefee',
name='orders',
),
]

View file

@ -354,17 +354,18 @@ class Order(models.Model):
return self.starting_date + datetime.timedelta(seconds=self.recurring_period) return self.starting_date + datetime.timedelta(seconds=self.recurring_period)
@property @property
def next_ending_date(self): def next_cancel_or_downgrade_date(self):
""" """
Return the next proper ending date after n times the Return the next proper ending date after n times the
recurring_period, where n is an integer. recurring_period, where n is an integer that applies for downgrading
or cancelling.
""" """
if self.recurring_period > 0: if self.recurring_period > 0:
now = timezone.now() now = timezone.now()
delta = now - self.starting_date delta = now - self.starting_date
num_times = math.ceil(delta.total_seconds() / self.recurring_period) num_times = ceil(delta.total_seconds() / self.recurring_period)
next_date = self.starting_date + datetime.timedelta(seconds= num_times * self.recurring_period) next_date = self.starting_date + datetime.timedelta(seconds= num_times * self.recurring_period)
else: else:
@ -452,8 +453,9 @@ class Order(models.Model):
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")
if self.ending_date and self.ending_date < self.earliest_ending_date: # do not check this if we upgrade
raise ValidationError("Ending date is before minimum duration (starting_date + recurring period)") # if self.ending_date and self.ending_date < self.earliest_ending_date:
# raise ValidationError("Ending date is before minimum duration (starting_date + recurring period)")
super().save(*args, **kwargs) super().save(*args, **kwargs)
@ -853,11 +855,9 @@ class BillRecord(models.Model):
class Product(UncloudModel): class Product(UncloudModel):
""" """
A product is something a user orders. To record the pricing, we A product is something a user can order. To record the pricing, we
create order that define a state in time. create order that define a state in time.
A product can *depend* on other products.
A product can have *one* one_time_order and/or *one* A product can have *one* one_time_order and/or *one*
recurring_order. recurring_order.
@ -872,12 +872,12 @@ class Product(UncloudModel):
description = "Generic Product" description = "Generic Product"
orders = models.ManyToManyField(Order)
status = models.CharField(max_length=32, status = models.CharField(max_length=32,
choices=UncloudStatus.choices, choices=UncloudStatus.choices,
default=UncloudStatus.AWAITING_PAYMENT) default=UncloudStatus.AWAITING_PAYMENT)
# config = models.JSONField()
# Default period for all products # Default period for all products
default_recurring_period = RecurringPeriod.PER_30D default_recurring_period = RecurringPeriod.PER_30D
@ -941,6 +941,7 @@ class Product(UncloudModel):
self.orders.add(recurring_order) 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): def create_or_update_recurring_order(self, when_to_start=None, recurring_period=None):
if not self.recurring_price: if not self.recurring_price:
return return
@ -954,8 +955,8 @@ class Product(UncloudModel):
if self.last_recurring_order: if self.last_recurring_order:
if self.recurring_price < self.last_recurring_order.price: if self.recurring_price < self.last_recurring_order.price:
if when_to_start < self.last_recurring_order.next_ending_date: if when_to_start < self.last_recurring_order.next_cancel_or_downgrade_date:
when_to_start = start_after(self.last_recurring_order.next_ending_date) when_to_start = start_after(self.last_recurring_order.next_cancel_or_downgrade_date)
when_to_end = end_before(when_to_start) when_to_end = end_before(when_to_start)
@ -967,10 +968,9 @@ class Product(UncloudModel):
description=str(self), description=str(self),
replaces=self.last_recurring_order) replaces=self.last_recurring_order)
self.last_recurring_order.end_date = when_to_end self.last_recurring_order.replace_with(new_order)
self.orders.add(new_order) self.orders.add(new_order)
else: else:
# This might be a bug as it might (re-)create the one time order
self.create_order(when_to_start, recurring_period) self.create_order(when_to_start, recurring_period)
@property @property

View file

@ -1,6 +1,7 @@
from django.test import TestCase from django.test import TestCase
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from datetime import datetime, date, timedelta from datetime import datetime, date, timedelta
from django.utils import timezone
from .models import * from .models import *
from uncloud_service.models import GenericServiceProduct from uncloud_service.models import GenericServiceProduct
@ -93,7 +94,9 @@ class ProductTestCase(TestCase):
p = SampleRecurringProduct.objects.create(owner=self.user) p = SampleRecurringProduct.objects.create(owner=self.user)
p.create_order(timezone.make_aware(datetime.datetime(2020,3,3))) p.create_order(timezone.make_aware(datetime.datetime(2020,3,3)))
p.create_or_update_recurring_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): class BillingAddressTestCase(TestCase):
@ -413,38 +416,96 @@ class ModifyProductTestCase(TestCase):
self.assertNotEqual(bills[0].sum, pro_rata_amount * price) self.assertNotEqual(bills[0].sum, pro_rata_amount * price)
self.assertEqual(bills[0].sum, price) self.assertEqual(bills[0].sum, price)
def test_bill_for_increasing_product_easy(self):
def test_downgrade_product(self):
""" """
Modify product, check general logi 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
""" """
# Create product user = self.user
starting_date = timezone.make_aware(datetime.datetime(2019,3,3))
starting_price = 10 starting_price = 10
product = SampleRecurringProduct.objects.create(owner=self.user, downgrade_price = 5
rc_price=starting_price)
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.create_order(starting_date)
change1_date = timezone.make_aware(datetime.datetime(2019,4,17)) product.rc_price = downgrade_price
product.rc_price = 20
product.save() product.save()
product.create_or_update_recurring_order(when_to_start=change1_date) product.create_or_update_recurring_order(when_to_start=change1_date)
bill_ending_date = timezone.make_aware(datetime.datetime(2019,6,30)) bills = Bill.create_next_bills_for_user(user, ending_date=bill_ending_date)
bills = Bill.create_next_bills_for_user(self.user,
ending_date=bill_ending_date)
bill = bills[0] bill = bills[0]
bill_records = BillRecord.objects.filter(bill=bill) bill_records = BillRecord.objects.filter(bill=bill)
self.assertEqual(len(bill_records), 3) self.assertEqual(len(bill_records), 2)
self.assertEqual(int(bill.sum), 35)
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)
# Expected bill sum & records:
# 2019-03-03 - 2019-04-02 +30d: 10
# 2019-04-02 - 2019-04-17: +15d: 5
# 2019-04-17 - 2019-05-17: +30d: 20
# total: 35
# def test_bill_for_increasing_product(self): # def test_bill_for_increasing_product(self):

View file

@ -0,0 +1,21 @@
# Generated by Django 3.1 on 2020-09-28 18:44
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('uncloud_service', '0004_auto_20200809_1237'),
]
operations = [
migrations.RemoveField(
model_name='genericserviceproduct',
name='orders',
),
migrations.RemoveField(
model_name='matrixserviceproduct',
name='orders',
),
]

View file

@ -0,0 +1,25 @@
# Generated by Django 3.1 on 2020-09-28 18:44
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('uncloud_vm', '0004_auto_20200809_1237'),
]
operations = [
migrations.RemoveField(
model_name='vmdiskproduct',
name='orders',
),
migrations.RemoveField(
model_name='vmproduct',
name='orders',
),
migrations.RemoveField(
model_name='vmsnapshotproduct',
name='orders',
),
]