remove big mistake: orders from product
Signed-off-by: Nico Schottelius <nico@nico-notebook.schottelius.org>
This commit is contained in:
parent
d8a7964fed
commit
1aead50170
8 changed files with 287 additions and 35 deletions
|
@ -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
|
||||||
-
|
-
|
||||||
|
|
17
opennebula/migrations/0005_remove_vm_orders.py
Normal file
17
opennebula/migrations/0005_remove_vm_orders.py
Normal 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',
|
||||||
|
),
|
||||||
|
]
|
17
uncloud_net/migrations/0005_remove_vpnnetwork_orders.py
Normal file
17
uncloud_net/migrations/0005_remove_vpnnetwork_orders.py
Normal 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',
|
||||||
|
),
|
||||||
|
]
|
25
uncloud_pay/migrations/0015_auto_20200928_1844.py
Normal file
25
uncloud_pay/migrations/0015_auto_20200928_1844.py
Normal 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',
|
||||||
|
),
|
||||||
|
]
|
|
@ -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
|
||||||
|
|
|
@ -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):
|
||||||
|
|
21
uncloud_service/migrations/0005_auto_20200928_1844.py
Normal file
21
uncloud_service/migrations/0005_auto_20200928_1844.py
Normal 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',
|
||||||
|
),
|
||||||
|
]
|
25
uncloud_vm/migrations/0005_auto_20200928_1844.py
Normal file
25
uncloud_vm/migrations/0005_auto_20200928_1844.py
Normal 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',
|
||||||
|
),
|
||||||
|
]
|
Loading…
Reference in a new issue