in the middle of restructering

This commit is contained in:
Nico Schottelius 2020-05-24 13:45:03 +02:00
parent 5d1eaaf0af
commit a3f3ca8cf9
2 changed files with 103 additions and 386 deletions

View file

@ -31,6 +31,11 @@ logger = logging.getLogger(__name__)
# See https://docs.djangoproject.com/en/dev/ref/models/fields/#field-choices-enum-types # See https://docs.djangoproject.com/en/dev/ref/models/fields/#field-choices-enum-types
class RecurringPeriod(models.IntegerChoices): class RecurringPeriod(models.IntegerChoices):
"""
We don't support months are years, because the vary in length.
This is not only complicated, but also unfair to the user, as the user pays the same
amount for different durations.
"""
PER_365D = 365*24*3600, _('Per 365 days') PER_365D = 365*24*3600, _('Per 365 days')
PER_30D = 30*24*3600, _('Per 30 days') PER_30D = 30*24*3600, _('Per 30 days')
PER_WEEK = 7*24*3600, _('Per Week') PER_WEEK = 7*24*3600, _('Per Week')
@ -40,14 +45,13 @@ class RecurringPeriod(models.IntegerChoices):
PER_SECOND = 1, _('Per Second') PER_SECOND = 1, _('Per Second')
ONE_TIME = 0, _('Onetime') ONE_TIME = 0, _('Onetime')
class CountryField(models.CharField): class CountryField(models.CharField):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
kwargs.setdefault('choices', COUNTRIES) kwargs.setdefault('choices', COUNTRIES)
kwargs.setdefault('default', 'CH') kwargs.setdefault('default', 'CH')
kwargs.setdefault('max_length', 2) kwargs.setdefault('max_length', 2)
super(CountryField, self).__init__(*args, **kwargs) super().__init__(*args, **kwargs)
def get_internal_type(self): def get_internal_type(self):
return "CharField" return "CharField"
@ -246,7 +250,23 @@ class VATRate(models.Model):
logger.debug("Did not find VAT rate for %s, returning 0" % country_code) logger.debug("Did not find VAT rate for %s, returning 0" % country_code)
return 0 return 0
class BillNico(models.Model): class BillRecord(models.Model):
"""
Entry of a bill, dynamically generated from an order.
"""
bill = models.ForeignKey(Bill, on_delete=models.CASCADE)
# How many times the order has been used in this record
usage_count = models.IntegerField(default=1)
# The timeframe the bill record is for can (and probably often will) differ
# from the bill time
creation_date = models.DateTimeField(auto_now_add=True)
starting_date = models.DateTimeField()
ending_date = models.DateTimeField()
class Bill(models.Model):
""" FIXME: """ FIXME:
Bill needs to be unique in the triple (owner, year, month) Bill needs to be unique in the triple (owner, year, month)
""" """
@ -262,12 +282,20 @@ class BillNico(models.Model):
valid = models.BooleanField(default=True) valid = models.BooleanField(default=True)
@staticmethod # billing address and vat rate is the same for the whole bill
def create_all_bills(): # @property
# def vat_rate(self):
# return Decimal(VATRate.get_for_country(self.bill.billing_address.country))
@classmethod
def create_all_bills(cls):
for owner in get_user_model().objects.all(): for owner in get_user_model().objects.all():
# mintime = time of first order # mintime = time of first order
# maxtime = time of last order # maxtime = time of last order
# iterate month based through it # iterate month based through it
cls.assign_orders_to_bill(owner, year, month)
pass pass
def assign_orders_to_bill(self, owner, year, month): def assign_orders_to_bill(self, owner, year, month):
@ -291,359 +319,55 @@ class BillNico(models.Model):
# FIXME: add something to check whether the order should be billed at all - i.e. a marker that # FIXME: add something to check whether the order should be billed at all - i.e. a marker that
# disables searching -> optimization for later # disables searching -> optimization for later
for order in Order.objects.filter(Q(starting_date__gte=self.starting_date), # Create the initial bill record
Q(starting_date__lte=self.ending_date), # FIXME: maybe limit not even to starting/ending date, but to empty_bill record -- to be fixed in the future
owner=owner): # for order in Order.objects.filter(Q(starting_date__gte=self.starting_date),
# Q(starting_date__lte=self.ending_date),
# order.bill.add(this_bill) # FIXME below: only check for active orders
pass
# Ensure all orders of that owner have at least one bill record
for order in Order.objects.filter(owner=owner,
bill_records=None):
bill_record = BillRecord.objects.create(bill=self,
usage_count=1,
starting_date=order.starting_date,
ending_date=order.starting_date + timedelta(seconds=order.recurring_period))
""" # For each recurring order get the usage and bill it
Find all recurring orders that did not start in this time frame, but need
to be billed in this time frame.
This is:
- order starting time before our starting time
- order start time + (x * (the_period)) is inside our time frame, x must be integer
test cases:
+ 365days:
time_since_last_billed = self.starting_or_ending_date - order.last_bill_date
periods =
[ we could in theory add this as a property to the order: next
"""
for order in Order.objects.filter(~Q(recurring_period=RecurringPeriod.ONE_TIME), for order in Order.objects.filter(~Q(recurring_period=RecurringPeriod.ONE_TIME),
Q(starting_date__lt=self.starting_date), Q(starting_date__lt=self.starting_date),
owner=owner): owner=owner):
if order.recurring_period > 0: # avoid div/0 - these are one time payments if order.recurring_period > 0: # avoid div/0 - these are one time payments
pass
# How much time will have passed by the end of the billing cycle
td = self.ending_date - order.starting_date
# How MANY times it will have been used by then
used_times = ceil(td / timedelta(seconds=order.recurring_period))
class Bill(models.Model): billed_times = len(order.bills)
uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
owner = models.ForeignKey(get_user_model(),
on_delete=models.CASCADE)
creation_date = models.DateTimeField(auto_now_add=True) # How many times it WAS billed -- can also be inferred from the bills that link to it!
starting_date = models.DateTimeField() if used_times > billed_times:
ending_date = models.DateTimeField() billing_times = used_times - billed_times
due_date = models.DateField()
valid = models.BooleanField(default=True) # ALSO REGISTER THE TIME PERIOD!
pass
# Trigger product activation if bill paid at creation (from balance).
def save(self, *args, **kwargs):
super(Bill, self).save(*args, **kwargs)
if not self in Bill.get_unpaid_for(self.owner):
self.activate_products()
@property
def reference(self):
return "{}-{}".format(
self.owner.username,
self.creation_date.strftime("%Y-%m-%d-%H%M"))
@property
def records(self):
bill_records = []
orders = Order.objects.filter(bill=self)
for order in orders:
bill_record = BillRecord(self, order)
bill_records.append(bill_record)
return bill_records
@property
def amount(self):
return reduce(lambda acc, record: acc + record.amount, self.records, 0)
@property
def vat_amount(self):
return reduce(lambda acc, record: acc + record.vat_amount, self.records, 0)
@property
def total(self):
return self.amount + self.vat_amount
@property
def final(self):
# A bill is final when its ending date is passed, or when all of its
# orders have been terminated.
every_order_terminated = True
billing_period_is_over = self.ending_date < timezone.now()
for order in self.order_set.all():
every_order_terminated = every_order_terminated and order.is_terminated
return billing_period_is_over or every_order_terminated
def activate_products(self):
for order in self.order_set.all():
# FIXME: using __something might not be a good idea.
for product_class in Product.__subclasses__():
for product in product_class.objects.filter(order=order):
if product.status == UncloudStatus.AWAITING_PAYMENT:
product.status = UncloudStatus.PENDING
product.save()
@property
def billing_address(self):
orders = Order.objects.filter(bill=self)
# The genrate_for method makes sure all the orders of a bill share the
# same billing address. TODO: It would be nice to enforce that somehow...
if orders:
return orders[0].billing_address
else:
return None
# TODO: split this huuuge method!
@staticmethod
def generate_for(year, month, user):
# /!\ We exclusively work on the specified year and month.
generated_bills = []
# Default values for next bill (if any).
starting_date=beginning_of_month(year, month)
ending_date=end_of_month(year, month)
creation_date=timezone.now()
# Select all orders active on the request period (i.e. starting on or after starting_date).
orders = Order.objects.filter(
Q(ending_date__gte=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
# * For monthly bills: if previous_bill.ending_date is before
# (next_bill) ending_date, a new bill has to be generated.
# * For yearly bill: if previous_bill.ending_date is on working
# month, generate new bill.
unpaid_orders = { 'monthly_or_less': [], 'yearly': {} }
for order in orders:
try:
previous_bill = order.bill.latest('ending_date')
except ObjectDoesNotExist:
previous_bill = None
# FIXME: control flow is confusing in this block.
# if order.recurring_period == RecurringPeriod.PER_YEAR:
# # We ignore anything smaller than a day in here.
# next_yearly_bill_start_on = None
# if previous_bill == None:
# next_yearly_bill_start_on = order.starting_date
# elif previous_bill.ending_date <= ending_date:
# next_yearly_bill_start_on = (previous_bill.ending_date + timedelta(days=1))
# # Store for bill generation. One bucket per day of month with a starting bill.
# # bucket is a reference here, no need to reassign.
# if next_yearly_bill_start_on:
# # We want to group orders by date but keep using datetimes.
# next_yearly_bill_start_on = next_yearly_bill_start_on.replace(
# minute=0, hour=0, second=0, microsecond=0)
# bucket = unpaid_orders['yearly'].get(next_yearly_bill_start_on)
# if bucket == None:
# unpaid_orders['yearly'][next_yearly_bill_start_on] = [order]
# else:
# unpaid_orders['yearly'][next_yearly_bill_start_on] = bucket + [order]
# else:
# if previous_bill == None or previous_bill.ending_date < ending_date:
# unpaid_orders['monthly_or_less'].append(order)
# Handle working month's billing.
if len(unpaid_orders['monthly_or_less']) > 0:
# TODO: PREPAID billing is not supported yet.
prepaid_due_date = min(creation_date, starting_date) + BILL_PAYMENT_DELAY
postpaid_due_date = max(creation_date, ending_date) + BILL_PAYMENT_DELAY
# There should not be any bill linked to orders with different
# billing addresses.
per_address_orders = itertools.groupby(
unpaid_orders['monthly_or_less'],
lambda o: o.billing_address)
for addr, bill_orders in per_address_orders:
next_monthly_bill = Bill.objects.create(owner=user,
creation_date=creation_date,
starting_date=starting_date, # FIXME: this is a hack!
ending_date=ending_date,
due_date=postpaid_due_date)
# It is not possible to register many-to-many relationship before
# the two end-objects are saved in database.
for order in bill_orders:
order.bill.add(next_monthly_bill)
logger.info("Generated monthly bill {} (amount: {}) for user {}."
.format(next_monthly_bill.uuid, next_monthly_bill.total, user))
# Add to output.
generated_bills.append(next_monthly_bill)
# Handle yearly bills starting on working month.
if len(unpaid_orders['yearly']) > 0:
# For every starting date, generate new bill.
for next_yearly_bill_start_on in unpaid_orders['yearly']:
# No postpaid for yearly payments.
prepaid_due_date = min(creation_date, next_yearly_bill_start_on) + BILL_PAYMENT_DELAY
# Bump by one year, remove one day.
ending_date = next_yearly_bill_start_on.replace(
year=next_yearly_bill_start_on.year+1) - timedelta(days=1)
# There should not be any bill linked to orders with different
# billing addresses.
per_address_orders = itertools.groupby(
unpaid_orders['yearly'][next_yearly_bill_start_on],
lambda o: o.billing_address)
for addr, bill_orders in per_address_orders:
next_yearly_bill = Bill.objects.create(owner=user,
creation_date=creation_date,
starting_date=next_yearly_bill_start_on,
ending_date=ending_date,
due_date=prepaid_due_date)
# It is not possible to register many-to-many relationship before
# the two end-objects are saved in database.
for order in bill_orders:
order.bill.add(next_yearly_bill)
logger.info("Generated yearly bill {} (amount: {}) for user {}."
.format(next_yearly_bill.uuid, next_yearly_bill.total, user))
# Add to output.
generated_bills.append(next_yearly_bill)
# Return generated (monthly + yearly) bills.
return generated_bills
@staticmethod
def get_unpaid_for(user):
balance = get_balance_for_user(user)
unpaid_bills = []
# No unpaid bill if balance is positive.
if balance >= 0:
return unpaid_bills
else:
bills = Bill.objects.filter(
owner=user,
).order_by('-creation_date')
# Amount to be paid by the customer.
unpaid_balance = abs(balance)
for bill in bills:
if unpaid_balance <= 0:
break
unpaid_balance -= bill.total
unpaid_bills.append(bill)
return unpaid_bills
@staticmethod
def get_overdue_for(user):
unpaid_bills = Bill.get_unpaid_for(user)
return list(filter(lambda bill: bill.due_date > timezone.now(), unpaid_bills))
class BillRecord():
"""
Entry of a bill, dynamically generated from an order.
"""
def __init__(self, bill, order):
self.bill = bill
self.order = order
self.recurring_price = order.recurring_price
self.recurring_period = order.recurring_period
self.description = order.description
if self.order.starting_date >= self.bill.starting_date:
self.one_time_price = order.one_time_price
else:
self.one_time_price = 0
# Set decimal context for amount computations.
# XXX: understand why we need +1 here.
decimal.getcontext().prec = AMOUNT_DECIMALS + 1
@property
def recurring_count(self):
# Compute billing delta.
billed_until = self.bill.ending_date
if self.order.ending_date != None and self.order.ending_date <= self.bill.ending_date:
billed_until = self.order.ending_date
billed_from = self.bill.starting_date
if self.order.starting_date > self.bill.starting_date:
billed_from = self.order.starting_date
if billed_from > billed_until:
# TODO: think about and check edge cases. This should not be
# possible.
raise Exception('Impossible billing delta!')
billed_delta = billed_until - billed_from
# TODO: refactor this thing?
# TODO: weekly
# if self.recurring_period == RecurringPeriod.PER_YEAR:
# # XXX: Should always be one => we do not bill for more than one year.
# # TODO: check billed_delta is ~365 days.
# return 1
# elif self.recurring_period == RecurringPeriod.PER_MONTH:
# days = ceil(billed_delta / timedelta(days=1))
# # Monthly bills always cover one single month.
# if (self.bill.starting_date.year != self.bill.starting_date.year or
# self.bill.starting_date.month != self.bill.ending_date.month):
# raise Exception('Bill {} covers more than one month. Cannot bill PER_MONTH.'.
# format(self.bill.uuid))
# # XXX: minumal length of monthly order is to be enforced somewhere else.
# (_, days_in_month) = monthrange(
# self.bill.starting_date.year,
# self.bill.starting_date.month)
# return round(days / days_in_month, AMOUNT_DECIMALS)
if self.recurring_period == RecurringPeriod.PER_WEEK:
weeks = ceil(billed_delta / timedelta(week=1))
return weeks
elif self.recurring_period == RecurringPeriod.PER_DAY:
days = ceil(billed_delta / timedelta(days=1))
return days
elif self.recurring_period == RecurringPeriod.PER_HOUR:
hours = ceil(billed_delta / timedelta(hours=1))
return hours
elif self.recurring_period == RecurringPeriod.PER_SECOND:
seconds = ceil(billed_delta / timedelta(seconds=1))
return seconds
elif self.recurring_period == RecurringPeriod.ONE_TIME:
return 0
else:
raise Exception('Unsupported recurring period: {}.'.
format(self.order.recurring_period))
@property
def vat_rate(self):
return Decimal(VATRate.get_for_country(self.bill.billing_address.country))
@property
def vat_amount(self):
return self.amount * self.vat_rate
@property
def amount(self):
return Decimal(float(self.recurring_price) * self.recurring_count) + self.one_time_price
@property
def total(self):
return self.amount + self.vat_amount
### ###
# Orders. # Orders.
# Order are assumed IMMUTABLE and used as SOURCE OF TRUST for generating
# bills. Do **NOT** mutate then!
class Order(models.Model): class Order(models.Model):
"""
Order are assumed IMMUTABLE and used as SOURCE OF TRUST for generating
bills. Do **NOT** mutate then!
"""
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(),
on_delete=models.CASCADE, on_delete=models.CASCADE,
@ -658,11 +382,22 @@ class Order(models.Model):
ending_date = models.DateTimeField(blank=True, ending_date = models.DateTimeField(blank=True,
null=True) null=True)
bill = models.ManyToManyField(Bill, bill_records = models.ManyToManyField(BillRecord,
editable=False, editable=False,
blank=True) blank=True)
recurring_period = models.IntegerField(choices = RecurringPeriod.choices, default = RecurringPeriod.PER_30D) @property
def count_billed(self):
"""
How many times this order was billed so far.
This logic is mainly thought to be for recurring bills, but also works for one time bills
"""
return sum([ br.usage_count for br in self.bill_records.all() ])
recurring_period = models.IntegerField(choices = RecurringPeriod.choices,
default = RecurringPeriod.PER_30D)
one_time_price = models.DecimalField(default=0.0, one_time_price = models.DecimalField(default=0.0,
max_digits=AMOUNT_MAX_DIGITS, max_digits=AMOUNT_MAX_DIGITS,
@ -696,7 +431,6 @@ class Order(models.Model):
@property @property
def is_recurring(self): def is_recurring(self):
return not self.recurring_period == RecurringPeriod.ONE_TIME return not self.recurring_period == RecurringPeriod.ONE_TIME
@property @property
def is_terminated(self): def is_terminated(self):
return self.ending_date != None and self.ending_date < timezone.now() return self.ending_date != None and self.ending_date < timezone.now()
@ -709,9 +443,6 @@ class Order(models.Model):
self.ending_date = timezone.now() self.ending_date = timezone.now()
self.save() self.save()
def is_to_be_charged_in(year, month):
pass
# Trigger initial bill generation at order creation. # Trigger initial bill generation at order creation.
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
if self.ending_date and self.ending_date < self.starting_date: if self.ending_date and self.ending_date < self.starting_date:
@ -749,35 +480,6 @@ class Order(models.Model):
self.one_time_price, self.one_time_price,
self.recurring_price) self.recurring_price)
class OrderTimothee(models.Model):
uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
owner = models.ForeignKey(get_user_model(),
on_delete=models.CASCADE,
editable=False)
billing_address = models.ForeignKey(BillingAddress, on_delete=models.CASCADE)
# TODO: enforce ending_date - starting_date to be larger than recurring_period.
creation_date = models.DateTimeField(auto_now_add=True)
starting_date = models.DateTimeField(default=timezone.now)
ending_date = models.DateTimeField(blank=True,
null=True)
bill = models.ManyToManyField(Bill,
editable=False,
blank=True)
recurring_period = models.IntegerField(choices = RecurringPeriod.choices,
default = RecurringPeriod.PER_30D)
# Trigger initial bill generation at order creation.
def save(self, *args, **kwargs):
if self.ending_date and self.ending_date < self.starting_date:
raise ValidationError("End date cannot be before starting date")
super().save(*args, **kwargs)
Bill.generate_for(self.starting_date.year, self.starting_date.month, self.owner)
@property @property
def records(self): def records(self):
return OrderRecord.objects.filter(order=self) return OrderRecord.objects.filter(order=self)
@ -988,3 +690,16 @@ class Product(UncloudModel):
else: else:
# FIXME: use the right type of exception here! # FIXME: use the right type of exception here!
raise Exception("Did not implement the discounter for this case") raise Exception("Did not implement the discounter for this case")
# Interesting snippets
# # Trigger initial bill generation at order creation.
# def save(self, *args, **kwargs):
# if self.ending_date and self.ending_date < self.starting_date:
# raise ValidationError("End date cannot be before starting date")
# super().save(*args, **kwargs)
# Bill.generate_for(self.starting_date.year, self.starting_date.month, self.owner)

View file

@ -5,7 +5,8 @@ from datetime import datetime, date, timedelta
from .models import * from .models import *
from uncloud_service.models import GenericServiceProduct from uncloud_service.models import GenericServiceProduct
class BillingTestCase(TestCase): class NotABillingTC(TestCase):
#class BillingTestCase(TestCase):
def setUp(self): def setUp(self):
self.user = get_user_model().objects.create( self.user = get_user_model().objects.create(
username='jdoe', username='jdoe',
@ -22,15 +23,16 @@ class BillingTestCase(TestCase):
description = "Test Product 1" description = "Test Product 1"
# Three months: full, full, partial. # Three months: full, full, partial.
starting_date = datetime.fromisoformat('2020-03-01') # starting_date = datetime.fromisoformat('2020-03-01')
ending_date = datetime.fromisoformat('2020-05-08') starting_date = datetime(2020,3,1)
ending_date = datetime(2020,5,8)
# Create order to be billed. # Create order to be billed.
order = Order.objects.create( order = Order.objects.create(
owner=self.user, owner=self.user,
starting_date=starting_date, starting_date=starting_date,
ending_date=ending_date, ending_date=ending_date,
recurring_period=RecurringPeriod.PER_MONTH, recurring_period=RecurringPeriod.PER_30D,
recurring_price=recurring_price, recurring_price=recurring_price,
one_time_price=one_time_price, one_time_price=one_time_price,
description=description, description=description,
@ -67,7 +69,7 @@ class BillingTestCase(TestCase):
order = Order.objects.create( order = Order.objects.create(
owner=self.user, owner=self.user,
starting_date=starting_date, starting_date=starting_date,
recurring_period=RecurringPeriod.PER_YEAR, recurring_period=RecurringPeriod.PER_365D,
recurring_price=recurring_price, recurring_price=recurring_price,
one_time_price=one_time_price, one_time_price=one_time_price,
description=description, description=description,
@ -150,7 +152,7 @@ class ProductActivationTestCase(TestCase):
order = Order.objects.create( order = Order.objects.create(
owner=self.user, owner=self.user,
starting_date=starting_date, starting_date=starting_date,
recurring_period=RecurringPeriod.PER_MONTH, recurring_period=RecurringPeriod.PER_30D,
recurring_price=recurring_price, recurring_price=recurring_price,
one_time_price=one_time_price, one_time_price=one_time_price,
description=description, description=description,
@ -205,12 +207,12 @@ class BillingAddressTestCase(TestCase):
order_01 = Order.objects.create( order_01 = Order.objects.create(
owner=self.user, owner=self.user,
starting_date=starting_date, starting_date=starting_date,
recurring_period=RecurringPeriod.PER_MONTH, recurring_period=RecurringPeriod.PER_30D,
billing_address=self.billing_address_01) billing_address=self.billing_address_01)
order_02 = Order.objects.create( order_02 = Order.objects.create(
owner=self.user, owner=self.user,
starting_date=starting_date, starting_date=starting_date,
recurring_period=RecurringPeriod.PER_MONTH, recurring_period=RecurringPeriod.PER_30D,
billing_address=self.billing_address_01) billing_address=self.billing_address_01)
# We need a single bill since we work with a single address. # We need a single bill since we work with a single address.
@ -225,12 +227,12 @@ class BillingAddressTestCase(TestCase):
order_01 = Order.objects.create( order_01 = Order.objects.create(
owner=self.user, owner=self.user,
starting_date=starting_date, starting_date=starting_date,
recurring_period=RecurringPeriod.PER_MONTH, recurring_period=RecurringPeriod.PER_30D,
billing_address=self.billing_address_01) billing_address=self.billing_address_01)
order_02 = Order.objects.create( order_02 = Order.objects.create(
owner=self.user, owner=self.user,
starting_date=starting_date, starting_date=starting_date,
recurring_period=RecurringPeriod.PER_MONTH, recurring_period=RecurringPeriod.PER_30D,
billing_address=self.billing_address_02) billing_address=self.billing_address_02)
# We need different bills since we work with different addresses. # We need different bills since we work with different addresses.