diff --git a/uncloud/uncloud_pay/helpers.py b/uncloud/uncloud_pay/helpers.py index 8ca82aa..248fbb4 100644 --- a/uncloud/uncloud_pay/helpers.py +++ b/uncloud/uncloud_pay/helpers.py @@ -1,7 +1,10 @@ from functools import reduce +from datetime import datetime from rest_framework import mixins from rest_framework.viewsets import GenericViewSet -from .models import Bill, Payment, PaymentMethod +from django.db.models import Q +from .models import Bill, Payment, PaymentMethod, Order +from django.utils import timezone def sum_amounts(entries): return reduce(lambda acc, entry: acc + entry.amount, entries, 0) @@ -20,6 +23,52 @@ def get_payment_method_for(user): return None +def beginning_of_month(date): + return datetime(year=date.year, date=now.month, day=0) + +def generate_bills_for(year, month, user, allowed_delay): + # /!\ We exclusively work on the specified year and month. + + # Default values for next bill (if any). Only saved at the end of + # this method, if relevant. + tz = timezone.get_current_timezone() + next_bill = Bill(owner=user, + starting_date=datetime(year=year, month=month, day=1, tzinfo=tz), + ending_date=datetime(year=year, month=month, day=28, tzinfo=tz), + creation_date=timezone.now(), + due_date=timezone.now() + allowed_delay) + + # Select all orders active on the request period. + orders = Order.objects.filter( + Q(ending_date__gt=next_bill.starting_date) | Q(ending_date__isnull=True), + owner=user) + + # Check if there is already a bill covering the order and period pair: + # * Get latest bill by ending_date: previous_bill.ending_date + # * If previous_bill.ending_date is before next_bill.ending_date, a new + # bill has to be generated. + unpaid_orders = [] + for order in orders: + try: + previous_bill = order.bill.latest('-ending_date') + except ObjectDoesNotExist: + previous_bill = None + + if previous_bill == None or previous_bill.ending_date < next_bill.ending_date: + unpaid_orders.append(order) + + # Commit next_bill if it there are 'unpaid' orders. + if len(unpaid_orders) > 0: + next_bill.save() + + # It is not possible to register many-to-many relationship before + # the two end-objects are saved in database. + for order in unpaid_orders: + order.bill.add(next_bill) + + return next_bill + + # Return None if no bill was created. class ProductViewSet(mixins.CreateModelMixin, mixins.RetrieveModelMixin, diff --git a/uncloud/uncloud_pay/management/commands/generate-bills.py b/uncloud/uncloud_pay/management/commands/generate-bills.py index aad7a82..34432d5 100644 --- a/uncloud/uncloud_pay/management/commands/generate-bills.py +++ b/uncloud/uncloud_pay/management/commands/generate-bills.py @@ -1,67 +1,38 @@ +import logging + from django.core.management.base import BaseCommand from uncloud_auth.models import User from uncloud_pay.models import Order, Bill from django.core.exceptions import ObjectDoesNotExist -from datetime import timedelta +from datetime import timedelta, date from django.utils import timezone +from uncloud_pay.helpers import generate_bills_for BILL_PAYMENT_DELAY=timedelta(days=10) +logger = logging.getLogger(__name__) + class Command(BaseCommand): help = 'Generate bills and charge customers if necessary.' def add_arguments(self, parser): pass + # TODO: use logger.* def handle(self, *args, **options): + # Iterate over all 'active' users. + # TODO: filter out inactive users. users = User.objects.all() print("Processing {} users.".format(users.count())) for user in users: - # Fetch all the orders of a customer. - orders = Order.objects.filter(owner=user) - - # Default values for next bill (if any). Only saved at the end of - # this method, if relevant. - next_bill = Bill(owner=user, - starting_date=timezone.now(), # Will be set to oldest unpaid order (means unpaid starting date). - ending_date=timezone.now(), # Bill covers everything until today. - due_date=timezone.now() + BILL_PAYMENT_DELAY) - - unpaid_orders = [] # Store orders in need of a payment. - for order in orders: - # Only bill if there is an 'unpaid period' on an active order. - # XXX: Assume everything before latest bill is paid. => might be dangerous. - try: - previous_bill = order.bill.latest('ending_date') - except ObjectDoesNotExist: - previous_bill = None - - is_unpaid_period = True - if order.ending_date and previous_bill != None: - is_unpaid_period = previous_bill.ending_date < order.ending_date - - if is_unpaid_period: - # Update bill starting date to match period. - if previous_bill == None: - next_bill.starting_date = order.starting_date - elif previous_bill.ending_date < next_bill.starting_date: - next_bill.starting_date = previous_bill.ending_date - - # Add order to bill - unpaid_orders.append(order) - - # Save next_bill if it contains any unpaid product. - if len(unpaid_orders) > 0: - next_bill.save() - - # It is not possible to register many-to-many relationship before - # the two end-objects are saved in database. - for order in unpaid_orders: - order.bill.add(next_bill) - - print("Created bill {} for user {}".format(next_bill.uuid, user.username)) + now = timezone.now() + generate_bills_for( + year=now.year, + month=now.month, + user=user, + allowed_delay=BILL_PAYMENT_DELAY) # We're done for this round :-) print("=> Done.") diff --git a/uncloud/uncloud_pay/models.py b/uncloud/uncloud_pay/models.py index f5639c4..f9e7c35 100644 --- a/uncloud/uncloud_pay/models.py +++ b/uncloud/uncloud_pay/models.py @@ -2,6 +2,7 @@ from django.db import models from django.contrib.auth import get_user_model from django.core.validators import MinValueValidator from django.utils.translation import gettext_lazy as _ +from django.utils import timezone import uuid @@ -31,16 +32,49 @@ class Bill(models.Model): valid = models.BooleanField(default=True) @property - def amount(self): - orders = Order.objects.filter(bill=self) - amount = 0 - for order in orders: - amount += order.recurring_price - - return amount + def entries(self): + # TODO: return list of Bill entries, extract from linked order + # for each related order + # for each product + # build BillEntry + return [] + @property + def total(self): + #return helpers.sum_amounts(self.entries) + pass + +class BillEntry(): + start_date = timezone.now() + end_date = timezone.now() + recurring_period = RecurringPeriod.PER_MONTH + recurring_price = 0 + amount = 0 + description = "" +# /!\ BIG FAT WARNING /!\ # +# +# Order are assumed IMMUTABLE and used as SOURCE OF TRUST for generating +# bills. Do **NOT** mutate then! +# +# Why? We need to store the state somewhere since product are mutable (e.g. +# adding RAM to VM, changing price of 1GB of RAM, ...). An alternative could +# have been to only store the state in bills but would have been more +# confusing: the order is a 'contract' with the customer, were both parts +# agree on deal => That's what we want to keep archived. +# +# SOON: +# +# We'll need to add some kind of OrderEntry table (each order might have +# multiple entries) storing: recurring_price, recurring_period, setup_fee, description +# +# FOR NOW: +# +# We dynamically get pricing from linked product, as they are not updated in +# this stage of development. +# +# /!\ BIG FAT WARNING /!\ # class Order(models.Model): uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) owner = models.ForeignKey(get_user_model(), @@ -56,22 +90,11 @@ class Order(models.Model): editable=False, blank=True) - - recurring_price = models.DecimalField( - max_digits=AMOUNT_MAX_DIGITS, - decimal_places=AMOUNT_DECIMALS, - validators=[MinValueValidator(0)], - editable=False) - one_time_price = models.DecimalField( - max_digits=AMOUNT_MAX_DIGITS, - decimal_places=AMOUNT_DECIMALS, - validators=[MinValueValidator(0)], - editable=False) - recurring_period = models.CharField(max_length=32, choices = RecurringPeriod.choices, default = RecurringPeriod.PER_MONTH) + # def amount(self): # amount = recurring_price # if recurring and first_month: @@ -133,9 +156,6 @@ class Payment(models.Model): default='unknown') timestamp = models.DateTimeField(editable=False, auto_now_add=True) - - - class Product(models.Model): uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) owner = models.ForeignKey(get_user_model(), diff --git a/uncloud/uncloud_vm/migrations/0009_auto_20200228_1416.py b/uncloud/uncloud_vm/migrations/0009_auto_20200228_1416.py new file mode 100644 index 0000000..e29bfe9 --- /dev/null +++ b/uncloud/uncloud_vm/migrations/0009_auto_20200228_1416.py @@ -0,0 +1,18 @@ +# Generated by Django 3.0.3 on 2020-02-28 14:16 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_vm', '0008_vmproduct_name'), + ] + + operations = [ + migrations.AlterField( + model_name='vmproduct', + name='name', + field=models.CharField(max_length=32), + ), + ] diff --git a/uncloud/ungleich_service/migrations/0002_matrixserviceproduct_domain.py b/uncloud/ungleich_service/migrations/0002_matrixserviceproduct_domain.py new file mode 100644 index 0000000..fda0075 --- /dev/null +++ b/uncloud/ungleich_service/migrations/0002_matrixserviceproduct_domain.py @@ -0,0 +1,18 @@ +# Generated by Django 3.0.3 on 2020-02-28 14:16 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('ungleich_service', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='matrixserviceproduct', + name='domain', + field=models.CharField(default='domain.tld', max_length=255), + ), + ] diff --git a/uncloud/ungleich_service/serializers.py b/uncloud/ungleich_service/serializers.py index ffd206f..0c34dcf 100644 --- a/uncloud/ungleich_service/serializers.py +++ b/uncloud/ungleich_service/serializers.py @@ -1,6 +1,7 @@ from rest_framework import serializers from .models import MatrixServiceProduct from uncloud_vm.serializers import VMProductSerializer +from uncloud_vm.models import VMProduct class MatrixServiceProductSerializer(serializers.ModelSerializer): vm = VMProductSerializer() @@ -9,3 +10,9 @@ class MatrixServiceProductSerializer(serializers.ModelSerializer): model = MatrixServiceProduct fields = ['uuid', 'order', 'owner', 'status', 'vm', 'domain'] read_only_fields = ['uuid', 'order', 'owner', 'status'] + + def create(self, validated_data): + # Create VM + vm_data = validated_data.pop('vm') + vm = VMProduct.objects.create(**vm_data) + return MatrixServiceProduct.create(vm=vm, **validated_data) diff --git a/uncloud/ungleich_service/views.py b/uncloud/ungleich_service/views.py index 9c27df8..a8de2e0 100644 --- a/uncloud/ungleich_service/views.py +++ b/uncloud/ungleich_service/views.py @@ -13,5 +13,5 @@ class MatrixServiceProductViewSet(ProductViewSet): return MatrixServiceProduct.objects.filter(owner=self.request.user) def create(self, request): - # TODO - pass + # TODO: create order, register service + return Response('{"HIT!"}')