from django.db import models from django.db.models import Q from django.contrib.auth import get_user_model from django.utils.translation import gettext_lazy as _ from django.core.validators import MinValueValidator from django.utils import timezone from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType import logging from functools import reduce import itertools from math import ceil import datetime from calendar import monthrange from decimal import Decimal import uncloud_pay.stripe from uncloud_pay import AMOUNT_DECIMALS, AMOUNT_MAX_DIGITS, COUNTRIES from uncloud.models import UncloudModel, UncloudStatus from decimal import Decimal import decimal # Used to generate bill due dates. BILL_PAYMENT_DELAY=datetime.timedelta(days=10) # Initialize logger. logger = logging.getLogger(__name__) def start_of_month(a_day): """ Returns first of the month of a given datetime object""" return a_day.replace(day=1,hour=0,minute=0,second=0, microsecond=0) def end_of_month(a_day): """ Returns first of the month of a given datetime object""" _, last_day = monthrange(a_day.year, a_day.month) return a_day.replace(day=last_day,hour=23,minute=59,second=59, microsecond=0) def start_of_this_month(): """ Returns first of this month""" a_day = timezone.now() return a_day.replace(day=1,hour=0,minute=0,second=0, microsecond=0) def end_of_this_month(): """ Returns first of this month""" a_day = timezone.now() _, last_day = monthrange(a_day.year, a_day.month) return a_day.replace(day=last_day,hour=23,minute=59,second=59, microsecond=0) def end_before(a_date): """ Return suitable datetimefield for ending just before a_date """ return a_date - datetime.timedelta(seconds=1) def start_after(a_date): """ Return suitable datetimefield for starting just after a_date """ return a_date + datetime.timedelta(seconds=1) def default_payment_delay(): return timezone.now() + BILL_PAYMENT_DELAY class Currency(models.TextChoices): """ Possible currencies to be billed """ CHF = 'CHF', _('Swiss Franc') EUR = 'EUR', _('Euro') USD = 'USD', _('US Dollar') class CountryField(models.CharField): def __init__(self, *args, **kwargs): kwargs.setdefault('choices', COUNTRIES) kwargs.setdefault('default', 'CH') kwargs.setdefault('max_length', 2) super().__init__(*args, **kwargs) def get_internal_type(self): return "CharField" def get_balance_for_user(user): bills = reduce( lambda acc, entry: acc + entry.total, Bill.objects.filter(owner=user), 0) payments = reduce( lambda acc, entry: acc + entry.amount, Payment.objects.filter(owner=user), 0) return payments - bills ### # Stripe class StripeCustomer(models.Model): owner = models.OneToOneField( get_user_model(), primary_key=True, on_delete=models.CASCADE) stripe_id = models.CharField(max_length=32) ### # Hosting company configuration class HostingProvider(models.Model): """ A class resembling who is running this uncloud instance. This might change over time so we allow starting/ending dates This also defines the taxation rules WIP. """ starting_date = models.DateField() ending_date = models.DateField() ### # Payments and Payment Methods. class Payment(models.Model): owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE) amount = models.DecimalField( default=0.0, max_digits=AMOUNT_MAX_DIGITS, decimal_places=AMOUNT_DECIMALS, validators=[MinValueValidator(0)]) source = models.CharField(max_length=256, choices = ( ('wire', 'Wire Transfer'), ('stripe', 'Stripe'), ('voucher', 'Voucher'), ('referral', 'Referral'), ('unknown', 'Unknown') ), default='unknown') timestamp = models.DateTimeField(editable=False, auto_now_add=True) # We override save() in order to active products awaiting payment. def save(self, *args, **kwargs): # _state.adding is switched to false after super(...) call. being_created = self._state.adding unpaid_bills_before_payment = Bill.get_unpaid_for(self.owner) super(Payment, self).save(*args, **kwargs) # Save payment in DB. unpaid_bills_after_payment = Bill.get_unpaid_for(self.owner) newly_paid_bills = list( set(unpaid_bills_before_payment) - set(unpaid_bills_after_payment)) for bill in newly_paid_bills: bill.activate_products() class PaymentMethod(models.Model): owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE, editable=False) source = models.CharField(max_length=256, choices = ( ('stripe', 'Stripe'), ('unknown', 'Unknown'), ), default='stripe') description = models.TextField() primary = models.BooleanField(default=False, editable=False) # Only used for "Stripe" source stripe_payment_method_id = models.CharField(max_length=32, blank=True, null=True) stripe_setup_intent_id = models.CharField(max_length=32, blank=True, null=True) @property def stripe_card_last4(self): if self.source == 'stripe' and self.active: payment_method = uncloud_pay.stripe.get_payment_method( self.stripe_payment_method_id) return payment_method.card.last4 else: return None @property def active(self): if self.source == 'stripe' and self.stripe_payment_method_id != None: return True else: return False def charge(self, amount): if not self.active: raise Exception('This payment method is inactive.') if amount < 0: # Make sure we don't charge negative amount by errors... raise Exception('Cannot charge negative amount.') if self.source == 'stripe': stripe_customer = StripeCustomer.objects.get(owner=self.owner).stripe_id stripe_payment = uncloud_pay.stripe.charge_customer( amount, stripe_customer, self.stripe_payment_method_id) if 'paid' in stripe_payment and stripe_payment['paid'] == False: raise Exception(stripe_payment['error']) else: payment = Payment.objects.create( owner=self.owner, source=self.source, amount=amount) return payment else: raise Exception('This payment method is unsupported/cannot be charged.') def set_as_primary_for(self, user): methods = PaymentMethod.objects.filter(owner=user, primary=True) for method in methods: print(method) method.primary = False method.save() self.primary = True self.save() def get_primary_for(user): methods = PaymentMethod.objects.filter(owner=user) for method in methods: # Do we want to do something with non-primary method? if method.active and method.primary: return method return None class Meta: # TODO: limit to one primary method per user. # unique_together is no good since it won't allow more than one # non-primary method. pass # See https://docs.djangoproject.com/en/dev/ref/models/fields/#field-choices-enum-types class RecurringPeriodChoices(models.IntegerChoices): """ This is an old class and being superseeded by the database model below """ PER_365D = 365*24*3600, _('Per 365 days') PER_30D = 30*24*3600, _('Per 30 days') PER_WEEK = 7*24*3600, _('Per Week') PER_DAY = 24*3600, _('Per Day') PER_HOUR = 3600, _('Per Hour') PER_MINUTE = 60, _('Per Minute') PER_SECOND = 1, _('Per Second') ONE_TIME = 0, _('Onetime') # RecurringPeriods class RecurringPeriod(models.Model): """ Available recurring periods. By default seeded from RecurringPeriodChoices """ name = models.CharField(max_length=100, unique=True) duration_seconds = models.IntegerField(unique=True) @classmethod def populate_db_defaults(cls): for (seconds, name) in RecurringPeriodChoices.choices: obj, created = cls.objects.get_or_create(name=name, defaults={ 'duration_seconds': seconds }) @staticmethod def secs_to_name(secs): name = "" days = 0 hours = 0 if secs > 24*3600: days = secs // (24*3600) secs -= (days*24*3600) if secs > 3600: hours = secs // 3600 secs -= hours*3600 return f"{days} days {hours} hours {secs} seconds" def __str__(self): duration = self.secs_to_name(self.duration_seconds) return f"{self.name} ({duration})" ### # Bills. class BillingAddress(models.Model): owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE) organization = models.CharField(max_length=100, blank=True, null=True) name = models.CharField(max_length=100) street = models.CharField(max_length=100) city = models.CharField(max_length=50) postal_code = models.CharField(max_length=50) country = CountryField(blank=True) vat_number = models.CharField(max_length=100, default="", blank=True) active = models.BooleanField(default=False) class Meta: constraints = [ models.UniqueConstraint(fields=['owner'], condition=Q(active=True), name='one_active_billing_address_per_user') ] @staticmethod def get_address_for(user): return BillingAddress.objects.get(owner=user, active=True) def __str__(self): return "{} - {}, {}, {} {}, {}".format( self.owner, self.name, self.street, self.postal_code, self.city, self.country) ### # VAT class VATRate(models.Model): starting_date = models.DateField(blank=True, null=True) ending_date = models.DateField(blank=True, null=True) territory_codes = models.TextField(blank=True, default='') currency_code = models.CharField(max_length=10) rate = models.FloatField() rate_type = models.TextField(blank=True, default='') description = models.TextField(blank=True, default='') @staticmethod def get_for_country(country_code): vat_rate = None try: vat_rate = VATRate.objects.get( territory_codes=country_code, start_date__isnull=False, stop_date=None ) return vat_rate.rate except VATRate.DoesNotExist as dne: logger.debug(str(dne)) logger.debug("Did not find VAT rate for %s, returning 0" % country_code) return 0 ### # Products class Product(UncloudModel): """ A product is something a user can order. To record the pricing, we create order that define a state in time. A product can have *one* one_time_order and/or *one* recurring_order. If either of them needs to be updated, a new order of the same type will be created and links to the previous order. """ name = models.CharField(max_length=256, unique=True) description = models.CharField(max_length=1024) config = models.JSONField() recurring_periods = models.ManyToManyField(RecurringPeriod, through='ProductToRecurringPeriod') currency = models.CharField(max_length=32, choices=Currency.choices, default=Currency.CHF) @property def default_recurring_period(self): """ Return the default recurring Period """ return self.recurring_periods.get(producttorecurringperiod__is_default=True) @classmethod def populate_db_defaults(cls): recurring_period = RecurringPeriod.objects.get(name="Per 30 days") obj, created = cls.objects.get_or_create(name="Dual Stack Virtual Machine v1", description="A standard virtual machine", currency=Currency.CHF, config={ 'features': { 'cores': { 'min': 1, 'max': 48, 'one_time_price_per_unit': 0, 'recurring_price_per_unit': 3 }, 'ram_gb': { 'min': 1, 'max': 256, 'one_time_price_per_unit': 0, 'recurring_price_per_unit': 4 }, 'ssd_gb': { 'min': 10, 'one_time_price_per_unit': 0, 'recurring_price_per_unit': 0.35 }, 'hdd_gb': { 'min': 0, 'one_time_price_per_unit': 0, 'recurring_price_per_unit': 15/1000 }, 'additional_ipv4_address': { 'min': 0, 'one_time_price_per_unit': 0, 'recurring_price_per_unit': 8 }, } } ) obj.recurring_periods.add(recurring_period, through_defaults= { 'is_default': True }) obj, created = cls.objects.get_or_create(name="Dual Stack Virtual Machine v2", description="A standard virtual machine", currency=Currency.CHF, config={ 'features': { 'base': { 'min': 1, 'max': 1, 'one_time_price_per_unit': 0, 'recurring_price_per_unit': 1 }, 'cores': { 'min': 1, 'max': 48, 'one_time_price_per_unit': 0, 'recurring_price_per_unit': 3 }, 'ram_gb': { 'min': 1, 'max': 256, 'one_time_price_per_unit': 0, 'recurring_price_per_unit': 4 }, 'ssd_gb': { 'min': 10, 'one_time_price_per_unit': 0, 'recurring_price_per_unit': 0.35 }, 'hdd_gb': { 'min': 0, 'one_time_price_per_unit': 0, 'recurring_price_per_unit': 15/1000 }, 'additional_ipv4_address': { 'min': 0, 'one_time_price_per_unit': 0, 'recurring_price_per_unit': 9 }, } } ) obj.recurring_periods.add(recurring_period, through_defaults= { 'is_default': True }) def __str__(self): return f"{self.name} - {self.description}" @property def recurring_orders(self): return self.orders.order_by('id').exclude(recurring_period=RecurringPeriod.ONE_TIME) @property def last_recurring_order(self): return self.recurring_orders.last() @property def one_time_orders(self): return self.orders.order_by('id').filter(recurring_period=RecurringPeriod.ONE_TIME) @property def last_one_time_order(self): return self.one_time_orders.last() def create_order(self, when_to_start=None, recurring_period=None): billing_address = BillingAddress.get_address_for(self.owner) if not billing_address: raise ValidationError("Cannot order without a billing address") if not when_to_start: when_to_start = timezone.now() if not recurring_period: recurring_period = self.default_recurring_period # Create one time order if we did not create one already if self.one_time_price > 0 and not self.last_one_time_order: one_time_order = Order.objects.create(owner=self.owner, billing_address=billing_address, starting_date=when_to_start, price=self.one_time_price, recurring_period=RecurringPeriod.ONE_TIME, description=str(self)) self.orders.add(one_time_order) else: one_time_order = None if recurring_period != RecurringPeriod.ONE_TIME: if one_time_order: recurring_order = Order.objects.create(owner=self.owner, billing_address=billing_address, starting_date=when_to_start, price=self.recurring_price, recurring_period=recurring_period, depends_on=one_time_order, description=str(self)) else: recurring_order = Order.objects.create(owner=self.owner, billing_address=billing_address, starting_date=when_to_start, price=self.recurring_price, recurring_period=recurring_period, description=str(self)) self.orders.add(recurring_order) # FIXME: this could/should be part of Order (?) def create_or_update_recurring_order(self, when_to_start=None, recurring_period=None): if not self.recurring_price: return if not recurring_period: recurring_period = self.default_recurring_period if not when_to_start: when_to_start = timezone.now() if self.last_recurring_order: if self.recurring_price < self.last_recurring_order.price: if when_to_start < self.last_recurring_order.next_cancel_or_downgrade_date: when_to_start = start_after(self.last_recurring_order.next_cancel_or_downgrade_date) when_to_end = end_before(when_to_start) new_order = Order.objects.create(owner=self.owner, billing_address=self.last_recurring_order.billing_address, starting_date=when_to_start, price=self.recurring_price, recurring_period=recurring_period, description=str(self), replaces=self.last_recurring_order) self.last_recurring_order.replace_with(new_order) self.orders.add(new_order) else: self.create_order(when_to_start, recurring_period) @property def is_recurring(self): return self.recurring_price > 0 @property def billing_address(self): return self.order.billing_address def discounted_price_by_period(self, requested_period): """ Each product has a standard recurring period for which we define a pricing. I.e. VPN is usually year, VM is usually monthly. The user can opt-in to use a different period, which influences the price: The longer a user commits, the higher the discount. Products can also be limited in the available periods. For instance a VPN only makes sense to be bought for at least one day. Rules are as follows: given a standard recurring period of ..., changing to ... modifies price ... # One month for free if buying / year, compared to a month: about 8.33% discount per_year -> per_month -> /11 per_month -> per_year -> *11 # Month has 30.42 days on average. About 7.9% discount to go monthly per_month -> per_day -> /28 per_day -> per_month -> *28 # Day has 24h, give one for free per_day -> per_hour -> /23 per_hour -> per_day -> /23 Examples VPN @ 120CHF/y becomes - 10.91 CHF/month (130.91 CHF/year) - 0.39 CHF/day (142.21 CHF/year) VM @ 15 CHF/month becomes - 165 CHF/month (13.75 CHF/month) - 0.54 CHF/day (16.30 CHF/month) """ # FIXME: This logic needs to be phased out / replaced by product specific (?) # proportions. Maybe using the RecurringPeriod table to link the possible discounts/add ups if self.default_recurring_period == RecurringPeriod.PER_365D: if requested_period == RecurringPeriod.PER_365D: return self.recurring_price if requested_period == RecurringPeriod.PER_30D: return self.recurring_price/11. if requested_period == RecurringPeriod.PER_DAY: return self.recurring_price/11./28. elif self.default_recurring_period == RecurringPeriod.PER_30D: if requested_period == RecurringPeriod.PER_365D: return self.recurring_price*11 if requested_period == RecurringPeriod.PER_30D: return self.recurring_price if requested_period == RecurringPeriod.PER_DAY: return self.recurring_price/28. elif self.default_recurring_period == RecurringPeriod.PER_DAY: if requested_period == RecurringPeriod.PER_365D: return self.recurring_price*11*28 if requested_period == RecurringPeriod.PER_30D: return self.recurring_price*28 if requested_period == RecurringPeriod.PER_DAY: return self.recurring_price else: # FIXME: use the right type of exception here! raise Exception("Did not implement the discounter for this case") def save(self, *args, **kwargs): # try: # ba = BillingAddress.get_address_for(self.owner) # except BillingAddress.DoesNotExist: # raise ValidationError("User does not have a billing address") # if not ba.active: # raise ValidationError("User does not have an active billing address") # Verify the required JSON fields super().save(*args, **kwargs) ### # Orders. class Order(models.Model): """ Order are assumed IMMUTABLE and used as SOURCE OF TRUST for generating bills. Do **NOT** mutate then! An one time order is "closed" (does not need to be billed anymore) if it has one bill record. Having more than one is a programming error. A recurring order is closed if it has been replaced (replaces__isnull=False) AND the ending_date is set AND it was billed the last time it needed to be billed (how to check the last item?) BOTH are closed, if they are ended/closed AND have been fully charged. Fully charged == fully billed: sum_of_order_usage == sum_of_bill_records """ owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE, editable=True) billing_address = models.ForeignKey(BillingAddress, on_delete=models.CASCADE) description = models.TextField() product = models.ForeignKey(Product, blank=False, null=False, on_delete=models.CASCADE) config = models.JSONField() creation_date = models.DateTimeField(auto_now_add=True) starting_date = models.DateTimeField(default=timezone.now) ending_date = models.DateTimeField(blank=True, null=True) recurring_period = models.ForeignKey(RecurringPeriod, on_delete=models.CASCADE, editable=True) one_time_price = models.DecimalField(default=0.0, max_digits=AMOUNT_MAX_DIGITS, decimal_places=AMOUNT_DECIMALS, validators=[MinValueValidator(0)]) recurring_price = models.DecimalField(default=0.0, max_digits=AMOUNT_MAX_DIGITS, decimal_places=AMOUNT_DECIMALS, validators=[MinValueValidator(0)]) currency = models.CharField(max_length=32, choices=Currency.choices, default=Currency.CHF) replaces = models.ForeignKey('self', related_name='replaced_by', on_delete=models.CASCADE, blank=True, null=True) depends_on = models.ForeignKey('self', related_name='parent_of', on_delete=models.CASCADE, blank=True, null=True) should_be_billed = models.BooleanField(default=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 + datetime.timedelta(seconds=self.recurring_period.duration_seconds) def next_cancel_or_downgrade_date(self, until_when=None): """ Return the next proper ending date after n times the recurring_period, where n is an integer that applies for downgrading or cancelling. """ if not until_when: until_when = timezone.now() if until_when < self.starting_date: raise ValidationError("Cannot end before start of start of order") if self.recurring_period.duration_seconds > 0: delta = until_when - self.starting_date num_times = ceil(delta.total_seconds() / self.recurring_period.duration_seconds) next_date = self.starting_date + datetime.timedelta(seconds=num_times * self.recurring_period.duration_seconds) else: next_date = self.starting_date return next_date def get_ending_date_for_bill(self, bill): """ Determine the ending date given a specific bill """ # If the order is quit, charge the final amount / finish (????) # Probably not a good idea -- FIXME :continue until usual if self.ending_date: this_ending_date = self.ending_date else: if self.next_cancel_or_downgrade_date(bill.ending_date) > bill.ending_date: this_ending_date = self.next_cancel_or_downgrade_date(bill.ending_date) else: this_ending_date = bill.ending_date return this_ending_date @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.quantity for br in self.bill_records.all() ]) def count_used(self, when=None): """ 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 """ if self.is_one_time: return 1 if not when: when = timezone.now() # Cannot be used after it ended if self.ending_date and when > self.ending_date: when = self.ending_date return (when - self.starting_date) / self.default_recurring_period @property def all_usage_billed(self, when=None): """ Returns true if this order does not need any further billing ever. In other words: is this order "closed"? """ if self.count_billed == self.count_used(when): return True else: return False @property def is_closed(self): if self.all_usage_billed and self.ending_date: return True else: return False @property def is_recurring(self): return not self.recurring_period == RecurringPeriod.ONE_TIME @property def is_one_time(self): return not self.is_recurring def replace_with(self, new_order): new_order.replaces = self self.ending_date = end_before(new_order.starting_date) self.save() def update_order(self, config, starting_date=None): """ Updating an order means creating a new order and reference the previous order """ if not starting_date: starting_date = timezone.now() new_order = self.__class__(owner=self.owner, billing_address=self.billing_address, description=self.description, product=self.product, config=config, starting_date=starting_date, currency=self.currency ) (new_order.one_time_price, new_order.recurring_price, new_order.config) = new_order.calculate_prices_and_config() new_order.replaces = self new_order.save() self.ending_date = end_before(new_order.starting_date) self.save() return new_order def create_bill_record(self, bill): br = None # Note: check for != 0 not > 0, as we allow discounts to be expressed with < 0 if self.one_time_price != 0 and self.billrecord_set.count() == 0: br = BillRecord.objects.create(bill=bill, order=self, starting_date=self.starting_date, ending_date=self.starting_date, is_recurring_record=False) if self.recurring_price != 0: br = BillRecord.objects.filter(bill=bill, order=self, is_recurring_record=True).first() if br: self.update_bill_record_for_recurring_order(br, bill) else: br = self.create_new_bill_record_for_recurring_order(bill) return br def update_bill_record_for_recurring_order(self, bill_record, bill): """ Possibly update a bill record according to the information in the bill """ # If the order has an ending date set, we might need to adjust the bill_record if self.ending_date: if bill_record_for_this_bill.ending_date != self.ending_date: bill_record_for_this_bill.ending_date = self.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() def create_new_bill_record_for_recurring_order(self, bill): """ Create a new bill record """ last_bill_record = BillRecord.objects.filter(order=self, is_recurring_record=True).order_by('id').last() starting_date=self.starting_date if last_bill_record: # We already charged beyond the end of this bill's period if last_bill_record.ending_date >= bill.ending_date: return # This order is terminated or replaced if self.ending_date: # And the last bill record already covered us -> nothing to be done anymore if last_bill_record.ending_date == self.ending_date: return starting_date = start_after(last_bill_record.ending_date) ending_date = self.get_ending_date_for_bill(bill) return BillRecord.objects.create(bill=bill, order=self, starting_date=starting_date, ending_date=ending_date, is_recurring_record=True) def calculate_prices_and_config(self): one_time_price = 0 recurring_price = 0 if self.config: config = self.config if 'features' not in self.config: self.config['features'] = {} else: config = { 'features': {} } # FIXME: adjust prices to the selected recurring_period to the if 'features' in self.product.config: for feature in self.product.config['features']: # Set min to 0 if not specified min_val = self.product.config['features'][feature].get('min', 0) # We might not even have 'features' cannot use .get() on it try: value = self.config['features'][feature] except (KeyError, TypeError): value = self.product.config['features'][feature]['min'] # Set max to current value if not specified max_val = self.product.config['features'][feature].get('max', value) if value < min_val or value > max_val: raise ValidationError(f"Feature '{feature}' must be at least {min_val} and at maximum {max_val}. Value is: {value}") one_time_price += self.product.config['features'][feature]['one_time_price_per_unit'] * value recurring_price += self.product.config['features'][feature]['recurring_price_per_unit'] * value config['features'][feature] = value return (one_time_price, recurring_price, config) def save(self, *args, **kwargs): # Calculate the price of the order when we create it # IMMUTABLE fields -- need to create new order to modify them # However this is not enforced here... if self._state.adding: (self.one_time_price, self.recurring_price, self.config) = self.calculate_prices_and_config() if self.recurring_period_id is None: self.recurring_period = self.product.default_recurring_period try: prod_period = self.product.recurring_periods.get(producttorecurringperiod__recurring_period=self.recurring_period) except ObjectDoesNotExist: raise ValidationError(f"Recurring Period {self.recurring_period} not allowed for product {self.product}") if self.ending_date and self.ending_date < self.starting_date: raise ValidationError("End date cannot be before starting date") super().save(*args, **kwargs) def __str__(self): return f"Order {self.id}: {self.description} {self.config}" 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) creation_date = models.DateTimeField(auto_now_add=True) starting_date = models.DateTimeField(default=start_of_this_month) ending_date = models.DateTimeField() due_date = models.DateField(default=default_payment_delay) billing_address = models.ForeignKey(BillingAddress, on_delete=models.CASCADE, editable=True, null=False) # FIXME: editable=True -> is in the admin, but also editable in DRF # Maybe filter fields in the serializer? is_final = models.BooleanField(default=False) class Meta: constraints = [ models.UniqueConstraint(fields=['owner', 'starting_date', 'ending_date' ], name='one_bill_per_month_per_user') ] def close(self): """ Close/finish a bill """ self.is_final = True self.save() @property def sum(self): bill_records = BillRecord.objects.filter(bill=self) return sum([ br.sum for br in bill_records ]) @classmethod def create_bills_for_all_users(cls): """ Create next bill for each user """ for owner in get_user_model().objects.all(): cls.create_next_bills_for_user(owner) @classmethod def create_next_bills_for_user(cls, owner, ending_date=None): """ Create one bill per billing address, as the VAT rates might be different for each address """ bills = [] for billing_address in BillingAddress.objects.filter(owner=owner): bills.append(cls.create_next_bill_for_user_address(billing_address, ending_date)) return bills @classmethod def create_next_bill_for_user_address(cls, billing_address, ending_date=None): """ Create the next bill for a specific billing address of a user """ owner = billing_address.owner all_orders = Order.objects.filter(owner=owner, billing_address=billing_address).order_by('id') bill = cls.get_or_create_bill(billing_address, ending_date=ending_date) for order in all_orders: order.create_bill_record(bill) return bill @classmethod def get_or_create_bill(cls, billing_address, ending_date=None): """ Get / reuse last bill if it is not yet closed Create bill, if there is no bill or if bill is closed. """ last_bill = cls.objects.filter(billing_address=billing_address).order_by('id').last() all_orders = Order.objects.filter(billing_address=billing_address).order_by('id') first_order = all_orders.first() bill = None # Get date & bill from previous bill, if it exists if last_bill: if not last_bill.is_final: bill = last_bill starting_date = last_bill.starting_date ending_date = bill.ending_date else: starting_date = last_bill.ending_date + datetime.timedelta(seconds=1) else: # Might be an idea to make this the start of the month, too if first_order: starting_date = first_order.starting_date else: starting_date = timezone.now() if not ending_date: ending_date = end_of_month(starting_date) if not bill: bill = cls.objects.create( owner=billing_address.owner, starting_date=starting_date, ending_date=ending_date, billing_address=billing_address) return bill def __str__(self): return f"Bill {self.owner}-{self.id}" class BillRecord(models.Model): """ Entry of a bill, dynamically generated from an order. """ bill = models.ForeignKey(Bill, on_delete=models.CASCADE) order = models.ForeignKey(Order, on_delete=models.CASCADE) creation_date = models.DateTimeField(auto_now_add=True) starting_date = models.DateTimeField() ending_date = models.DateTimeField() is_recurring_record = models.BooleanField(blank=False, null=False) @property def quantity(self): """ Determine the quantity by the duration""" if not self.is_recurring_record: return 1 record_delta = self.ending_date - self.starting_date return record_delta.total_seconds()/self.order.recurring_period.duration_seconds @property def sum(self): if self.is_recurring_record: return self.order.recurring_price * Decimal(self.quantity) else: return self.order.one_time_price def __str__(self): if self.is_recurring_record: bill_line = f"{self.starting_date} - {self.ending_date}: {self.quantity} x {self.order}" else: bill_line = f"{self.starting_date}: {self.order}" return bill_line def save(self, *args, **kwargs): if self.ending_date < self.starting_date: raise ValidationError("End date cannot be before starting date") super().save(*args, **kwargs) class ProductToRecurringPeriod(models.Model): """ Intermediate manytomany mapping class that allows storing the default recurring period for a product """ recurring_period = models.ForeignKey(RecurringPeriod, on_delete=models.CASCADE) product = models.ForeignKey(Product, on_delete=models.CASCADE) is_default = models.BooleanField(default=False) class Meta: constraints = [ models.UniqueConstraint(fields=['product'], condition=Q(is_default=True), name='one_default_recurring_period_per_product'), models.UniqueConstraint(fields=['product', 'recurring_period'], name='recurring_period_once_per_product') ] def __str__(self): return f"{self.product} - {self.recurring_period} (default: {self.is_default})"