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…
Reference in a new issue