WIP revamped bill logic

This commit is contained in:
fnux 2020-02-29 09:08:30 +01:00
parent af1265003e
commit e319d1d151
7 changed files with 152 additions and 69 deletions

View file

@ -1,7 +1,10 @@
from functools import reduce from functools import reduce
from datetime import datetime
from rest_framework import mixins from rest_framework import mixins
from rest_framework.viewsets import GenericViewSet 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): def sum_amounts(entries):
return reduce(lambda acc, entry: acc + entry.amount, entries, 0) return reduce(lambda acc, entry: acc + entry.amount, entries, 0)
@ -20,6 +23,52 @@ def get_payment_method_for(user):
return None 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, class ProductViewSet(mixins.CreateModelMixin,
mixins.RetrieveModelMixin, mixins.RetrieveModelMixin,

View file

@ -1,67 +1,38 @@
import logging
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from uncloud_auth.models import User from uncloud_auth.models import User
from uncloud_pay.models import Order, Bill from uncloud_pay.models import Order, Bill
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
from datetime import timedelta from datetime import timedelta, date
from django.utils import timezone from django.utils import timezone
from uncloud_pay.helpers import generate_bills_for
BILL_PAYMENT_DELAY=timedelta(days=10) BILL_PAYMENT_DELAY=timedelta(days=10)
logger = logging.getLogger(__name__)
class Command(BaseCommand): class Command(BaseCommand):
help = 'Generate bills and charge customers if necessary.' help = 'Generate bills and charge customers if necessary.'
def add_arguments(self, parser): def add_arguments(self, parser):
pass pass
# TODO: use logger.*
def handle(self, *args, **options): def handle(self, *args, **options):
# Iterate over all 'active' users.
# TODO: filter out inactive users.
users = User.objects.all() users = User.objects.all()
print("Processing {} users.".format(users.count())) print("Processing {} users.".format(users.count()))
for user in users: for user in users:
# Fetch all the orders of a customer. now = timezone.now()
orders = Order.objects.filter(owner=user) generate_bills_for(
year=now.year,
# Default values for next bill (if any). Only saved at the end of month=now.month,
# this method, if relevant. user=user,
next_bill = Bill(owner=user, allowed_delay=BILL_PAYMENT_DELAY)
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))
# We're done for this round :-) # We're done for this round :-)
print("=> Done.") print("=> Done.")

View file

@ -2,6 +2,7 @@ from django.db import models
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.core.validators import MinValueValidator from django.core.validators import MinValueValidator
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.utils import timezone
import uuid import uuid
@ -31,16 +32,49 @@ class Bill(models.Model):
valid = models.BooleanField(default=True) valid = models.BooleanField(default=True)
@property @property
def amount(self): def entries(self):
orders = Order.objects.filter(bill=self) # TODO: return list of Bill entries, extract from linked order
amount = 0 # for each related order
for order in orders: # for each product
amount += order.recurring_price # build BillEntry
return []
return amount
@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): class Order(models.Model):
uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
owner = models.ForeignKey(get_user_model(), owner = models.ForeignKey(get_user_model(),
@ -56,22 +90,11 @@ class Order(models.Model):
editable=False, editable=False,
blank=True) 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, recurring_period = models.CharField(max_length=32,
choices = RecurringPeriod.choices, choices = RecurringPeriod.choices,
default = RecurringPeriod.PER_MONTH) default = RecurringPeriod.PER_MONTH)
# def amount(self): # def amount(self):
# amount = recurring_price # amount = recurring_price
# if recurring and first_month: # if recurring and first_month:
@ -133,9 +156,6 @@ class Payment(models.Model):
default='unknown') default='unknown')
timestamp = models.DateTimeField(editable=False, auto_now_add=True) timestamp = models.DateTimeField(editable=False, auto_now_add=True)
class Product(models.Model): class Product(models.Model):
uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
owner = models.ForeignKey(get_user_model(), owner = models.ForeignKey(get_user_model(),

View 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),
),
]

View 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 = [
('ungleich_service', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='matrixserviceproduct',
name='domain',
field=models.CharField(default='domain.tld', max_length=255),
),
]

View file

@ -1,6 +1,7 @@
from rest_framework import serializers from rest_framework import serializers
from .models import MatrixServiceProduct from .models import MatrixServiceProduct
from uncloud_vm.serializers import VMProductSerializer from uncloud_vm.serializers import VMProductSerializer
from uncloud_vm.models import VMProduct
class MatrixServiceProductSerializer(serializers.ModelSerializer): class MatrixServiceProductSerializer(serializers.ModelSerializer):
vm = VMProductSerializer() vm = VMProductSerializer()
@ -9,3 +10,9 @@ class MatrixServiceProductSerializer(serializers.ModelSerializer):
model = MatrixServiceProduct model = MatrixServiceProduct
fields = ['uuid', 'order', 'owner', 'status', 'vm', 'domain'] fields = ['uuid', 'order', 'owner', 'status', 'vm', 'domain']
read_only_fields = ['uuid', 'order', 'owner', 'status'] 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)

View file

@ -13,5 +13,5 @@ class MatrixServiceProductViewSet(ProductViewSet):
return MatrixServiceProduct.objects.filter(owner=self.request.user) return MatrixServiceProduct.objects.filter(owner=self.request.user)
def create(self, request): def create(self, request):
# TODO # TODO: create order, register service
pass return Response('{"HIT!"}')