forked from uncloud/uncloud
		
	WIP revamped bill logic
This commit is contained in:
		
					parent
					
						
							
								af1265003e
							
						
					
				
			
			
				commit
				
					
						e319d1d151
					
				
			
		
					 7 changed files with 152 additions and 69 deletions
				
			
		| 
						 | 
				
			
			@ -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,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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.")
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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(),
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										18
									
								
								uncloud/uncloud_vm/migrations/0009_auto_20200228_1416.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								uncloud/uncloud_vm/migrations/0009_auto_20200228_1416.py
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -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),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
| 
						 | 
				
			
			@ -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),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
| 
						 | 
				
			
			@ -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)
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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!"}')
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue