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 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,
|
||||||
|
|
|
@ -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.")
|
||||||
|
|
|
@ -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(),
|
||||||
|
|
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 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)
|
||||||
|
|
|
@ -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!"}')
|
||||||
|
|
Loading…
Reference in a new issue