From e169b8c1d1d88b5ad943524461c3f36dff2297fc Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 9 Aug 2020 10:14:31 +0200 Subject: [PATCH] Implement the whole billing logic The major part has been written! --- uncloud_pay/models.py | 230 ++++++++++++++++++++++++------------------ 1 file changed, 133 insertions(+), 97 deletions(-) diff --git a/uncloud_pay/models.py b/uncloud_pay/models.py index 6d35b17..5f2b5d3 100644 --- a/uncloud_pay/models.py +++ b/uncloud_pay/models.py @@ -308,17 +308,28 @@ class Order(models.Model): replaces = models.ForeignKey('self', related_name='replaced_by', - on_delete=models.PROTECT, + on_delete=models.CASCADE, blank=True, null=True) depends_on = models.ForeignKey('self', related_name='parent_of', - on_delete=models.PROTECT, + on_delete=models.CASCADE, blank=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 def count_billed(self): """ @@ -328,14 +339,6 @@ class Order(models.Model): 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 def is_recurring(self): return not self.recurring_period == RecurringPeriod.ONE_TIME @@ -344,37 +347,46 @@ class Order(models.Model): def is_one_time(self): 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): if self.ending_date and self.ending_date < self.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) - 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): - 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): + """ + A bill is a representation of usage at a specific time + """ owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE) @@ -382,6 +394,7 @@ class Bill(models.Model): starting_date = models.DateTimeField(default=start_of_this_month) ending_date = models.DateTimeField() due_date = models.DateField(default=default_payment_delay) + is_final = models.BooleanField(default=False) class Meta: @@ -395,6 +408,11 @@ class Bill(models.Model): def __str__(self): return f"Bill {self.owner}-{self.id}" + @property + def billing_address(self): + pass +# if self.order_set.all + @property def sum(self): bill_records = BillRecord.objects.filter(bill=self) @@ -402,8 +420,11 @@ class Bill(models.Model): @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() + + # 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') first_order = all_orders.first() @@ -424,13 +445,10 @@ class Bill(models.Model): else: starting_date = timezone.now() - if not ending_date: ending_date = end_of_month(starting_date) - # create new bill, if previous is closed/does not exist if not bill: - bill = cls.objects.create( owner=owner, starting_date=starting_date, @@ -438,24 +456,96 @@ class Bill(models.Model): for order in all_orders: if order.is_one_time: + # this code should be ok, but needs to be double checked if order.billrecord_set.count() == 0: br = BillRecord.objects.create(bill=bill, order=order, - starting_date=starting_date, - ending_date=ending_date) + starting_date=order.starting_date, + ending_date=order.ending_date) 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, + order=order, + starting_date=this_starting_date, + ending_date=this_ending_date) + - br = BillRecord.objects.create(bill=bill, - order=order, - starting_date=starting_date, - ending_date=ending_date) # Filtering ideas: # 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 + # Find out the last billrecord for the order, if there is none, bill from the starting date + + return bill @classmethod @@ -467,66 +557,13 @@ class Bill(models.Model): 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), # Q(starting_date__lte=self.ending_date), - # FIXME below: only check for active orders - - # 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 order in Order.objects.filter(owner=owner, + # bill_records=None): # 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 if self.one_time_price > 0: - one_time_order = Order.objects.create(owner=self.owner, billing_address=billing_address, starting_date=when_to_start,