From a3f3ca8cf9ffcabe3003b5b277f6905c2b40e865 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 24 May 2020 13:45:03 +0200 Subject: [PATCH] in the middle of restructering --- uncloud_pay/models.py | 467 ++++++++---------------------------------- uncloud_pay/tests.py | 22 +- 2 files changed, 103 insertions(+), 386 deletions(-) diff --git a/uncloud_pay/models.py b/uncloud_pay/models.py index 8e6f4e1..bec64e1 100644 --- a/uncloud_pay/models.py +++ b/uncloud_pay/models.py @@ -31,6 +31,11 @@ logger = logging.getLogger(__name__) # See https://docs.djangoproject.com/en/dev/ref/models/fields/#field-choices-enum-types 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_30D = 30*24*3600, _('Per 30 days') PER_WEEK = 7*24*3600, _('Per Week') @@ -40,14 +45,13 @@ class RecurringPeriod(models.IntegerChoices): PER_SECOND = 1, _('Per Second') ONE_TIME = 0, _('Onetime') - class CountryField(models.CharField): def __init__(self, *args, **kwargs): kwargs.setdefault('choices', COUNTRIES) kwargs.setdefault('default', 'CH') kwargs.setdefault('max_length', 2) - super(CountryField, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) def get_internal_type(self): return "CharField" @@ -246,7 +250,23 @@ class VATRate(models.Model): logger.debug("Did not find VAT rate for %s, returning 0" % country_code) 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: Bill needs to be unique in the triple (owner, year, month) """ @@ -262,12 +282,20 @@ class BillNico(models.Model): valid = models.BooleanField(default=True) - @staticmethod - def create_all_bills(): + # billing address and vat rate is the same for the whole bill + # @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(): # mintime = time of first order # maxtime = time of last order # iterate month based through it + + cls.assign_orders_to_bill(owner, year, month) pass 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 # disables searching -> optimization for later - for order in Order.objects.filter(Q(starting_date__gte=self.starting_date), - Q(starting_date__lte=self.ending_date), - owner=owner): + # 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), -# order.bill.add(this_bill) - pass + # 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, + usage_count=1, + starting_date=order.starting_date, + ending_date=order.starting_date + timedelta(seconds=order.recurring_period)) - """ - 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 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 - 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): - uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - owner = models.ForeignKey(get_user_model(), - on_delete=models.CASCADE) + billed_times = len(order.bills) - creation_date = models.DateTimeField(auto_now_add=True) - starting_date = models.DateTimeField() - ending_date = models.DateTimeField() - due_date = models.DateField() + # 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 - 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. -# Order are assumed IMMUTABLE and used as SOURCE OF TRUST for generating -# bills. Do **NOT** mutate then! 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) owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE, @@ -658,11 +382,22 @@ class Order(models.Model): ending_date = models.DateTimeField(blank=True, null=True) - bill = models.ManyToManyField(Bill, - editable=False, - blank=True) + bill_records = models.ManyToManyField(BillRecord, + editable=False, + 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, max_digits=AMOUNT_MAX_DIGITS, @@ -696,7 +431,6 @@ class Order(models.Model): @property def is_recurring(self): return not self.recurring_period == RecurringPeriod.ONE_TIME - @property def is_terminated(self): 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.save() - def is_to_be_charged_in(year, month): - pass - # Trigger initial bill generation at order creation. def save(self, *args, **kwargs): if self.ending_date and self.ending_date < self.starting_date: @@ -749,35 +480,6 @@ class Order(models.Model): self.one_time_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 def records(self): return OrderRecord.objects.filter(order=self) @@ -988,3 +690,16 @@ class Product(UncloudModel): else: # FIXME: use the right type of exception here! 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) diff --git a/uncloud_pay/tests.py b/uncloud_pay/tests.py index 00ee294..7754d5b 100644 --- a/uncloud_pay/tests.py +++ b/uncloud_pay/tests.py @@ -5,7 +5,8 @@ from datetime import datetime, date, timedelta from .models import * from uncloud_service.models import GenericServiceProduct -class BillingTestCase(TestCase): +class NotABillingTC(TestCase): +#class BillingTestCase(TestCase): def setUp(self): self.user = get_user_model().objects.create( username='jdoe', @@ -22,15 +23,16 @@ class BillingTestCase(TestCase): description = "Test Product 1" # Three months: full, full, partial. - starting_date = datetime.fromisoformat('2020-03-01') - ending_date = datetime.fromisoformat('2020-05-08') +# starting_date = datetime.fromisoformat('2020-03-01') + starting_date = datetime(2020,3,1) + ending_date = datetime(2020,5,8) # Create order to be billed. order = Order.objects.create( owner=self.user, starting_date=starting_date, ending_date=ending_date, - recurring_period=RecurringPeriod.PER_MONTH, + recurring_period=RecurringPeriod.PER_30D, recurring_price=recurring_price, one_time_price=one_time_price, description=description, @@ -67,7 +69,7 @@ class BillingTestCase(TestCase): order = Order.objects.create( owner=self.user, starting_date=starting_date, - recurring_period=RecurringPeriod.PER_YEAR, + recurring_period=RecurringPeriod.PER_365D, recurring_price=recurring_price, one_time_price=one_time_price, description=description, @@ -150,7 +152,7 @@ class ProductActivationTestCase(TestCase): order = Order.objects.create( owner=self.user, starting_date=starting_date, - recurring_period=RecurringPeriod.PER_MONTH, + recurring_period=RecurringPeriod.PER_30D, recurring_price=recurring_price, one_time_price=one_time_price, description=description, @@ -205,12 +207,12 @@ class BillingAddressTestCase(TestCase): order_01 = Order.objects.create( owner=self.user, starting_date=starting_date, - recurring_period=RecurringPeriod.PER_MONTH, + recurring_period=RecurringPeriod.PER_30D, billing_address=self.billing_address_01) order_02 = Order.objects.create( owner=self.user, starting_date=starting_date, - recurring_period=RecurringPeriod.PER_MONTH, + recurring_period=RecurringPeriod.PER_30D, billing_address=self.billing_address_01) # We need a single bill since we work with a single address. @@ -225,12 +227,12 @@ class BillingAddressTestCase(TestCase): order_01 = Order.objects.create( owner=self.user, starting_date=starting_date, - recurring_period=RecurringPeriod.PER_MONTH, + recurring_period=RecurringPeriod.PER_30D, billing_address=self.billing_address_01) order_02 = Order.objects.create( owner=self.user, starting_date=starting_date, - recurring_period=RecurringPeriod.PER_MONTH, + recurring_period=RecurringPeriod.PER_30D, billing_address=self.billing_address_02) # We need different bills since we work with different addresses.