Implement the whole billing logic

The major part has been written!
This commit is contained in:
Nico Schottelius 2020-08-09 10:14:31 +02:00
parent d7c0c40926
commit e169b8c1d1

View file

@ -308,17 +308,28 @@ class Order(models.Model):
replaces = models.ForeignKey('self', replaces = models.ForeignKey('self',
related_name='replaced_by', related_name='replaced_by',
on_delete=models.PROTECT, on_delete=models.CASCADE,
blank=True, blank=True,
null=True) null=True)
depends_on = models.ForeignKey('self', depends_on = models.ForeignKey('self',
related_name='parent_of', related_name='parent_of',
on_delete=models.PROTECT, on_delete=models.CASCADE,
blank=True, blank=True,
null=True) null=True)
@property
def earliest_ending_date(self):
"""
Recurring orders cannot end before finishing at least one recurring period.
One time orders have a recurring period of 0, so this work universally
"""
return self.starting_date + timedelta(seconds=self.recurring_period)
@property @property
def count_billed(self): def count_billed(self):
""" """
@ -328,14 +339,6 @@ class Order(models.Model):
return sum([ br.quantity for br in self.bill_records.all() ]) return sum([ br.quantity for br in self.bill_records.all() ])
def active_before(self, ending_date):
# Was this order started before the specified ending date?
if self.starting_date <= ending_date:
if self.ending_date:
if self.ending_date > ending_date:
pass
@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
@ -344,37 +347,46 @@ class Order(models.Model):
def is_one_time(self): def is_one_time(self):
return not self.is_recurring return not self.is_recurring
@property
def is_terminated(self):
return self.ending_date != None and self.ending_date < timezone.now()
def is_terminated_at(self, a_date):
return self.ending_date != None and self.ending_date < timezone.now()
def terminate(self):
if not self.is_terminated:
self.ending_date = timezone.now()
self.save()
# 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:
raise ValidationError("End date cannot be before starting date") raise ValidationError("End date cannot be before starting date")
if self.ending_date and self.ending_date < self.earliest_ending_date:
raise ValidationError("Ending date is before minimum duration (starting_date + recurring period)")
super().save(*args, **kwargs) super().save(*args, **kwargs)
def generate_initial_bill(self):
return Bill.generate_for(self.starting_date.year, self.starting_date.month, self.owner)
@property
def records(self):
return OrderRecord.objects.filter(order=self)
def __str__(self): def __str__(self):
return f"Order {self.owner}-{self.id}" return f"{self.description} (order {self.id})"
# def active_before(self, ending_date):
# # Was this order started before the specified ending date?
# if self.starting_date <= ending_date:
# if self.ending_date:
# if self.ending_date > ending_date:
# pass
# Termination needs to be verified, maybe also include checking depending orders
# @property
# def is_terminated_now(self):
# return self.is_terminated_at(timezone.now())
# def is_terminated_at(self, a_date):
# return self.ending_date != None and self.ending_date <= a_date
# def terminate(self):
# if not self.is_terminated:
# self.ending_date = timezone.now()
# self.save()
class Bill(models.Model): class Bill(models.Model):
"""
A bill is a representation of usage at a specific time
"""
owner = models.ForeignKey(get_user_model(), owner = models.ForeignKey(get_user_model(),
on_delete=models.CASCADE) on_delete=models.CASCADE)
@ -382,6 +394,7 @@ class Bill(models.Model):
starting_date = models.DateTimeField(default=start_of_this_month) starting_date = models.DateTimeField(default=start_of_this_month)
ending_date = models.DateTimeField() ending_date = models.DateTimeField()
due_date = models.DateField(default=default_payment_delay) due_date = models.DateField(default=default_payment_delay)
is_final = models.BooleanField(default=False) is_final = models.BooleanField(default=False)
class Meta: class Meta:
@ -395,6 +408,11 @@ class Bill(models.Model):
def __str__(self): def __str__(self):
return f"Bill {self.owner}-{self.id}" return f"Bill {self.owner}-{self.id}"
@property
def billing_address(self):
pass
# if self.order_set.all
@property @property
def sum(self): def sum(self):
bill_records = BillRecord.objects.filter(bill=self) bill_records = BillRecord.objects.filter(bill=self)
@ -402,8 +420,11 @@ class Bill(models.Model):
@classmethod @classmethod
def create_next_bill_for_user(cls, owner): def create_next_bill_for_user(cls, owner, ending_date=None):
last_bill = cls.objects.filter(owner=owner).order_by('id').last() last_bill = cls.objects.filter(owner=owner).order_by('id').last()
# it is important to sort orders here, as bill records will be
# created (and listed) in this order
all_orders = Order.objects.filter(owner=owner).order_by('id') all_orders = Order.objects.filter(owner=owner).order_by('id')
first_order = all_orders.first() first_order = all_orders.first()
@ -424,13 +445,10 @@ class Bill(models.Model):
else: else:
starting_date = timezone.now() starting_date = timezone.now()
if not ending_date: if not ending_date:
ending_date = end_of_month(starting_date) ending_date = end_of_month(starting_date)
# create new bill, if previous is closed/does not exist
if not bill: if not bill:
bill = cls.objects.create( bill = cls.objects.create(
owner=owner, owner=owner,
starting_date=starting_date, starting_date=starting_date,
@ -438,24 +456,96 @@ class Bill(models.Model):
for order in all_orders: for order in all_orders:
if order.is_one_time: if order.is_one_time:
# this code should be ok, but needs to be double checked
if order.billrecord_set.count() == 0: if order.billrecord_set.count() == 0:
br = BillRecord.objects.create(bill=bill, br = BillRecord.objects.create(bill=bill,
order=order, order=order,
starting_date=starting_date, starting_date=order.starting_date,
ending_date=ending_date) ending_date=order.ending_date)
else: else:
# Bill all recurring orders -- filter in the next iteration :-) # Bill all recurring orders
bill_record_for_this_bill = BillRecord.objects.filter(bill=bill,
order=order).first()
# This bill already has a bill record for the order
# We potentially need to update the ending_date if the ending_date
# of the bill changed.
if bill_record_for_this_bill:
# we may need to adjust it, but let's do this logic another time
# If the order has an ending date set, we might need to adjust the bill_record
if order.ending_date:
if bill_record_for_this_bill.ending_date != order.ending_date:
bill_record_for_this_bill.ending_date = order.ending_date
else:
# recurring, not terminated, should go until at least end of bill
if bill_record_for_this_bill.ending_date < bill.ending_date:
bill_record_for_this_bill.ending_date = bill.ending_date
bill_record_for_this_bill.save()
else:
# No bill record in this bill for the order yet
# Find out whether it was already billed for the billing period of
# this bill
last_bill_record = BillRecord.objects.filter(order=order).order_by('id').last()
# Default starting date
this_starting_date=order.starting_date
# Skip billing again, if we have been processed for this bill duration
if last_bill_record:
if last_bill_record.ending_date >= bill.ending_date:
continue
# If the order ended and has been fully billed - do not process it
# anymore
if order.ending_date:
if last_bill_record.ending_date == order.ending_date:
# FIXME: maybe mark order for not processing anymore?
# I imagina a boolean column, once this code is stable and
# verified
continue
# Catch programming bugs if the last bill_record was
# created incorrectly - should never be entered!
if order.ending_date < last_bill_record.ending_date:
raise ValidationError(f"Order {order.id} ends before last bill record {last_bill_record.id}")
# Start right after last billing run
this_starting_date = last_bill_record.ending_date + datetime.timedelta(seconds=1)
# If the order is already terminated, use that date instead of bill date
if order.ending_date:
this_ending_date = order.ending_date
else:
if order.earliest_ending_date > bill.ending_date:
this_ending_date = order.earliest_ending_date
else:
# bill at maximum for this billing period
this_ending_date = bill.ending_date
# And finally create a new billrecord!
br = BillRecord.objects.create(bill=bill, br = BillRecord.objects.create(bill=bill,
order=order, order=order,
starting_date=starting_date, starting_date=this_starting_date,
ending_date=ending_date) ending_date=this_ending_date)
# Filtering ideas: # Filtering ideas:
# If order is replaced, it should not be added anymore if it has been billed "last time" # If order is replaced, it should not be added anymore if it has been billed "last time"
# If order has ended and finally charged, do not charge anymore # If order has ended and finally charged, do not charge anymore
# Find out the last billrecord for the order, if there is none, bill from the starting date
return bill return bill
@classmethod @classmethod
@ -467,66 +557,13 @@ class Bill(models.Model):
cls.create_next_bill_for_user(owner) cls.create_next_bill_for_user(owner)
def assign_orders_to_bill(self, owner, year, month):
"""
Generate a bill for the specific month of a user.
First handle all one time orders
FIXME:
- limit this to active users in the future! (2020-05-23)
"""
"""
Find all one time orders that have a starting date that falls into this month
recurring_period=RecurringPeriod.ONE_TIME,
Can we do this even for recurring / all of them
"""
# FIXME: add something to check whether the order should be billed at all - i.e. a marker that
# disables searching -> optimization for later
# Create the initial bill record
# FIXME: maybe limit not even to starting/ending date, but to empty_bill record -- to be fixed in the future
# for order in Order.objects.filter(Q(starting_date__gte=self.starting_date), # for order in Order.objects.filter(Q(starting_date__gte=self.starting_date),
# Q(starting_date__lte=self.ending_date), # Q(starting_date__lte=self.ending_date),
# FIXME below: only check for active orders #for order in Order.objects.filter(owner=owner,
# bill_records=None):
# 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,
quantity=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 # For each recurring order get the usage and bill it
for order in Order.objects.filter(~Q(recurring_period=RecurringPeriod.ONE_TIME),
Q(starting_date__lt=self.starting_date),
owner=owner):
if order.recurring_period > 0: # avoid div/0 - these are one time payments
# 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))
billed_times = len(order.bills)
# How many times it WAS billed -- can also be inferred from the bills that link to it!
if used_times > billed_times:
billing_times = used_times - billed_times
# ALSO REGISTER THE TIME PERIOD!
pass
@ -599,7 +636,6 @@ class Product(UncloudModel):
one_time_order = None one_time_order = None
if self.one_time_price > 0: if self.one_time_price > 0:
one_time_order = Order.objects.create(owner=self.owner, one_time_order = Order.objects.create(owner=self.owner,
billing_address=billing_address, billing_address=billing_address,
starting_date=when_to_start, starting_date=when_to_start,