import logging import itertools import datetime from math import ceil from calendar import monthrange from decimal import Decimal from functools import reduce from django.db import models from django.db.models import Q from django.contrib.auth import get_user_model from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType 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.conf import settings import uncloud_pay.stripe from uncloud_pay import AMOUNT_DECIMALS, AMOUNT_MAX_DIGITS from uncloud.models import UncloudAddress # 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') 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) ### # 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 RecurringPeriodDefaultChoices(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 RecurringPeriodDefaultChoices.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(UncloudAddress): owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE) 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') ] @classmethod def populate_db_defaults(cls): """ Ensure we have at least one billing address that is associated with the uncloud-admin. This way we are sure that an UncloudProvider can be created. Cannot use get_or_create as that looks for exactly one. """ owner = get_user_model().objects.get(username=settings.UNCLOUD_ADMIN_NAME) billing_address = cls.objects.filter(owner=owner).first() if not billing_address: billing_address = cls.objects.create(owner=owner, organization="uncloud admins", name="Uncloud Admin", street="Uncloudstreet. 42", city="Luchsingen", postal_code="8775", country="CH", active=True) @staticmethod def get_address_for(user): return BillingAddress.objects.get(owner=user, active=True) def __str__(self): return "{} - {}, {}, {} {}, {}".format( self.owner, self.full_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 def __str__(self): return f"{self.territory_codes}: {self.starting_date} - {self.ending_date}: {self.rate_type}" ### # Products class Product(models.Model): """ 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 }) obj, created = cls.objects.get_or_create(name="reverse DNS", description="Reverse DNS network", currency=Currency.CHF, config={ 'parameters': [ 'network' ] }) 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.objects.get(name="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.objects.get(name="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.objects.get(name="ONE_TIME"), description=str(self)) self.orders.add(one_time_order) else: one_time_order = None if recurring_period != RecurringPeriod.objects.get(name="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.objects.get(name="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 check_parameters(self): if 'parameters' in self.product.config: for parameter in self.product.config['parameters']: if not parameter in self.config['parameters']: raise ValidationError(f"Required parameter '{parameter}' is missing.") 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}") self.check_parameters() 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): try: conf = " ".join([ f"{key}:{val}" for key,val in self.config['features'].items() if val != 0 ]) except KeyError: conf = "" return f"Order {self.id}: {self.description} {conf}" 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 ]) @property def vat_rate(self): """ Handling VAT is a tricky business - thus we only implement the cases that we clearly now and leave it open to fellow developers to implement correct handling for other cases. Case CH: - If the customer is in .ch -> apply standard rate - If the customer is in EU AND private -> apply country specific rate - If the customer is in EU AND business -> do not apply VAT - If the customer is outside EU and outside CH -> do not apply VAT """ provider = UncloudProvider.objects.get() # Assume always VAT inside the country if provider.country == self.billing_address.country: vat_rate = VATRate.objects.get(country=provider.country, when=self.ending_date) elif self.billing_address.country in EU: # FIXME: need to check for validated vat number if self.billing_address.vat_number: return 0 else: return VATRate.objects.get(country=self.biling_address.country, when=self.ending_date) else: # non-EU, non-national return 0 @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 @property def price(self): if self.is_recurring_record: return self.order.recurring_price 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})"