diff --git a/doc/uncloud-manual-2020-08-01.org b/doc/uncloud-manual-2020-08-01.org index 4bd2876..d316d18 100644 --- a/doc/uncloud-manual-2020-08-01.org +++ b/doc/uncloud-manual-2020-08-01.org @@ -84,6 +84,14 @@ python manage.py migrate * URLs - api/ - the rest API * 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 *** How to add a new VPN Host **** Install wireguard to the host @@ -219,7 +227,85 @@ VPNNetworks can be managed by all authenticated users. - Product ***** TODO Recurring product support ****** TODO Support replacing orders for updates -****** TODO [#A] Finish split of bill creation -****** TODO [#A] Test the new functions in the Order class +****** DONE [#A] Finish split of bill creation + 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 - diff --git a/opennebula/migrations/0005_remove_vm_orders.py b/opennebula/migrations/0005_remove_vm_orders.py new file mode 100644 index 0000000..8426aec --- /dev/null +++ b/opennebula/migrations/0005_remove_vm_orders.py @@ -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', + ), + ] diff --git a/uncloud_net/migrations/0005_remove_vpnnetwork_orders.py b/uncloud_net/migrations/0005_remove_vpnnetwork_orders.py new file mode 100644 index 0000000..f7b607a --- /dev/null +++ b/uncloud_net/migrations/0005_remove_vpnnetwork_orders.py @@ -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', + ), + ] diff --git a/uncloud_pay/migrations/0015_auto_20200928_1844.py b/uncloud_pay/migrations/0015_auto_20200928_1844.py new file mode 100644 index 0000000..4aecb6e --- /dev/null +++ b/uncloud_pay/migrations/0015_auto_20200928_1844.py @@ -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', + ), + ] diff --git a/uncloud_pay/models.py b/uncloud_pay/models.py index 932888d..5b6299c 100644 --- a/uncloud_pay/models.py +++ b/uncloud_pay/models.py @@ -354,17 +354,18 @@ class Order(models.Model): return self.starting_date + datetime.timedelta(seconds=self.recurring_period) @property - def next_ending_date(self): + def next_cancel_or_downgrade_date(self): """ 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: now = timezone.now() 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) else: @@ -452,8 +453,9 @@ class Order(models.Model): if self.ending_date and self.ending_date < self.starting_date: raise ValidationError("End date cannot be before starting date") - if self.ending_date and self.ending_date < self.earliest_ending_date: - raise ValidationError("Ending date is before minimum duration (starting_date + recurring period)") + # do not check this if we upgrade + # 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) @@ -853,11 +855,9 @@ class BillRecord(models.Model): 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. - A product can *depend* on other products. - A product can have *one* one_time_order and/or *one* recurring_order. @@ -872,12 +872,12 @@ class Product(UncloudModel): description = "Generic Product" - orders = models.ManyToManyField(Order) - 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 @@ -941,6 +941,7 @@ class Product(UncloudModel): 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 @@ -954,8 +955,8 @@ class Product(UncloudModel): if self.last_recurring_order: if self.recurring_price < self.last_recurring_order.price: - if when_to_start < self.last_recurring_order.next_ending_date: - when_to_start = start_after(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_cancel_or_downgrade_date) when_to_end = end_before(when_to_start) @@ -967,10 +968,9 @@ class Product(UncloudModel): description=str(self), 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) else: - # This might be a bug as it might (re-)create the one time order self.create_order(when_to_start, recurring_period) @property diff --git a/uncloud_pay/tests.py b/uncloud_pay/tests.py index 3099ed3..bee3545 100644 --- a/uncloud_pay/tests.py +++ b/uncloud_pay/tests.py @@ -1,6 +1,7 @@ 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 @@ -93,7 +94,9 @@ class ProductTestCase(TestCase): 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,3))) + p.create_or_update_recurring_order(timezone.make_aware(datetime.datetime(2020,3,4))) + + # FIXME: where is the assert? class BillingAddressTestCase(TestCase): @@ -413,38 +416,96 @@ class ModifyProductTestCase(TestCase): self.assertNotEqual(bills[0].sum, pro_rata_amount * 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 - starting_date = timezone.make_aware(datetime.datetime(2019,3,3)) + user = self.user + + + starting_price = 10 - product = SampleRecurringProduct.objects.create(owner=self.user, - rc_price=starting_price) + 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) - change1_date = timezone.make_aware(datetime.datetime(2019,4,17)) - product.rc_price = 20 + product.rc_price = downgrade_price product.save() 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(self.user, - ending_date=bill_ending_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), 3) - self.assertEqual(int(bill.sum), 35) + 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) - # 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): diff --git a/uncloud_service/migrations/0005_auto_20200928_1844.py b/uncloud_service/migrations/0005_auto_20200928_1844.py new file mode 100644 index 0000000..7cc4b92 --- /dev/null +++ b/uncloud_service/migrations/0005_auto_20200928_1844.py @@ -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', + ), + ] diff --git a/uncloud_vm/migrations/0005_auto_20200928_1844.py b/uncloud_vm/migrations/0005_auto_20200928_1844.py new file mode 100644 index 0000000..0a28188 --- /dev/null +++ b/uncloud_vm/migrations/0005_auto_20200928_1844.py @@ -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', + ), + ]