import logging import datetime import json from math import ceil from calendar import monthrange from decimal import Decimal from django.conf import settings from django.contrib.auth import get_user_model from django.core.validators import MinValueValidator from django.db import models from django.db.models import Q from django.utils.translation import gettext_lazy as _ from django.utils import timezone # Verify whether or not to use them here from django.core.exceptions import ObjectDoesNotExist, ValidationError import uncloud_pay from uncloud import AMOUNT_DECIMALS, AMOUNT_MAX_DIGITS from uncloud.models import UncloudAddress, UncloudProvider from uncloud.selectors import filter_for_when from .services import * # Used to generate bill due dates. BILL_PAYMENT_DELAY=datetime.timedelta(days=settings.BILL_PAYMENT_DELAY) EU_COUNTRIES = ['at', 'be', 'bg', 'ch', 'cy', 'cz', 'hr', 'dk', 'ee', 'fi', 'fr', 'mc', 'de', 'gr', 'hu', 'ie', 'it', 'lv', 'lu', 'mt', 'nl', 'po', 'pt', 'ro','sk', 'si', 'es', 'se', 'gb'] # Initialize logger. logger = logging.getLogger(__name__) 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') ### # 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) def __str__(self): return self.owner.username class StripeCreditCard(models.Model): owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE) card_name = models.CharField(null=False, max_length=128, default="") card_id = models.CharField(null=False, max_length=32) last4 = models.CharField(null=False, max_length=4) brand = models.CharField(null=False, max_length=64) expiry_date = models.DateField(null=False) active = models.BooleanField(default=False) class Meta: constraints = [ models.UniqueConstraint(fields=['owner'], condition=models.Q(active=True), name='one_active_card_per_user') ] def __str__(self): return f"{self.card_name}: {self.brand} {self.last4} ({self.expiry_date})" def delete(self, **kwargs): uncloud_pay.stripe.delete_card(self.card_id) super().delete(**kwargs) def activate(self): StripeCreditCard.objects.filter(owner=self.owner, active=True).update(active=False) self.active = True self.save() class Payment(models.Model): owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE) type = models.CharField(max_length=256, choices = ( ('withdraw', 'Withdraw Money'), ('deposit', 'Deposit Money') ), null=False, blank=False, default="deposit") notes = models.TextField(default="", null=True, blank=True) amount = models.DecimalField( 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'), ), null=True, blank=True,) timestamp = models.DateTimeField(default=timezone.now) currency = models.CharField(max_length=32, choices=Currency.choices, default=Currency.CHF) external_reference = models.CharField(max_length=256, default="", null=True, blank=True) def __str__(self): return f"{self.amount}{self.currency} from {self.owner} via {self.source} on {self.timestamp}" @classmethod def deposit(cls, owner, amount, source, currency='CHF', notes=''): if source == 'stripe': try: payment_intent = uncloud_pay.stripe.charge_customer(owner, amount, currency) if not payment_intent.status or payment_intent.status != 'succeeded': raise Exception("The payment has been failed, please try to activate another card") return cls.objects.create(owner=owner, type="deposit", amount=amount, external_reference=payment_intent["id"], currency=currency, source=source, notes=notes) except Exception as e: raise e @classmethod def withdraw(cls, owner, amount, currency='CHF', notes=''): return cls.objects.create(owner=owner, type="withdraw", amount=amount, currency=currency, notes=notes) # 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, related_name='billing_addresses') vat_number = models.CharField(max_length=100, default="", blank=True) vat_number_verified = models.BooleanField(default=False) vat_number_validated_on = models.DateTimeField(blank=True, null=True) stripe_tax_id = models.CharField(max_length=100, default="", blank=True) active = models.BooleanField(default=False) class Meta: constraints = [ models.UniqueConstraint(fields=['owner'], condition=models.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", full_name="Uncloud Admin", street="Uncloudstreet. 42", city="Luchsingen", postal_code="8775", country="CH", active=True) def __str__(self): return "{} - {}, {}, {} {}, {}".format( self.owner, self.full_name, self.street, self.postal_code, self.city, self.country) @staticmethod def get_address_for(user): return BillingAddress.objects.get(owner=user) ### # 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_vat_rate_for_country(country, when=None): """ Returns the VAT rate for business to customer. B2B is always 0% with the exception of trading within the own country """ vatrate = filter_for_when(VATRate.objects.filter(territory_codes=country), when).first() # By default we charge VAT. This affects: # - Same country sales (VAT applied) # - B2C to EU (VAT applied) rate = vatrate.rate if vatrate else 0 if not country.lower().strip() in EU_COUNTRIES: rate = 0 return rate @staticmethod def get_vat_rate(billing_address, when=None): rate = VATRate.get_vat_rate_for_country(billing_address.country, when) # Exception: if... # - the billing_address is in EU, # - the vat_number has been set # - the vat_number has been verified # Then we do not charge VAT if rate != 0 and billing_address.vat_number and billing_address.vat_number_verified: rate = 0 return rate def __str__(self): return f"{self.territory_codes}: {self.starting_date} - {self.ending_date or ''}: {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 }, 'ram_gb': { 'min': 1, 'max': 256 }, 'ssd_gb': { 'min': 10 }, 'hdd_gb': { 'min': 0, }, 'additional_ipv4_address': { 'min': 0, }, } } ) 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, }, 'cores': { 'min': 1, 'max': 48, }, 'ram_gb': { 'min': 1, 'max': 256, }, 'ssd_gb': { 'min': 10 }, 'hdd_gb': { 'min': 0 }, 'additional_ipv4_address': { 'min': 0,}, } } ) 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}" @property def recurring_orders(self): return self.orders.order_by('id').exclude(recurring_price=0) @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_price=0) @property def last_one_time_order(self): return self.one_time_orders.last() # 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) ### # Pricing ###### import logging from django.db import models logger = logging.getLogger(__name__) class PricingPlan(models.Model): name = models.CharField(max_length=255, unique=True) vat_inclusive = models.BooleanField(default=True) vat_percentage = models.DecimalField( max_digits=7, decimal_places=5, blank=True, default=0 ) set_up_fees = models.DecimalField( max_digits=7, decimal_places=2, default=0 ) monthly_maintenance_fees = models.DecimalField( max_digits=7, decimal_places=2, default=0 ) cores_unit_price = models.DecimalField( max_digits=7, decimal_places=2, default=0 ) ram_unit_price = models.DecimalField( max_digits=7, decimal_places=2, default=0 ) storage_hd_unit_price = models.DecimalField( max_digits=7, decimal_places=2, default=0 ) storage_ssd_unit_price = models.DecimalField( max_digits=7, decimal_places=2, default=0 ) discount_name = models.CharField(max_length=255, null=True, blank=True) discount_amount = models.DecimalField( max_digits=6, decimal_places=2, default=0 ) stripe_coupon_id = models.CharField(max_length=255, null=True, blank=True) def __str__(self): display_str = self.name + ' => ' + ' - '.join([ '{} Setup'.format(self.set_up_fees.normalize()), '{}/Core'.format(self.cores_unit_price.normalize()), '{}/GB RAM'.format(self.ram_unit_price.normalize()), '{}/GB SSD'.format(self.storage_ssd_unit_price.normalize()), '{}/GB HD'.format(self.storage_hd_unit_price.normalize()), '{}% VAT'.format(self.vat_percentage.normalize()) if not self.vat_inclusive else 'VAT-Incl', ]) if self.discount_amount: display_str = ' - '.join([ display_str, '{} {}'.format( self.discount_amount, self.discount_name if self.discount_name else 'Discount' ) ]) return display_str @classmethod def get_by_name(cls, name): try: pricing = PricingPlan.objects.get(name=name) except Exception as e: logger.error( "Error getting VMPricing with name {name}. " "Details: {details}. Attempting to return default" "pricing.".format(name=name, details=str(e)) ) pricing = PricingPlan.get_default_pricing() return pricing @classmethod def get_default_pricing(cls): """ Returns the default pricing or None """ try: default_pricing = PricingPlan.objects.get(name='default') except Exception as e: logger.error(str(e)) default_pricing = None return default_pricing ### # 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) customer = models.ForeignKey(StripeCustomer, on_delete=models.CASCADE, null=True) 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) pricing_plan = models.ForeignKey(PricingPlan, blank=False, null=True, on_delete=models.CASCADE) 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 cancel(self): self.ending_date = timezone.now() self.should_be_billed = False self.save() 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_recurring(self): return self.recurring_price > 0 @property def is_one_time(self): return not self.is_recurring @property def description(self): desc = self.product.description + "( " config = json.loads(self.config) if config and config["cores"]: desc = f"{desc} {config['cores']} Cores," if config and config["memory"]: desc = f"{desc} {config['memory']} RAM," if config and config["storage"]: desc = f"{desc} {config['storage']} GB SSD," desc += " )" return desc 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, product=self.product, config=config, pricing_plan=self.pricing_plan, starting_date=starting_date, currency=self.currency ) new_order.recurring_price = new_order.calculate_recurring_price() 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 if self.recurring_price != 0: records = BillRecord.objects.filter(order=self).all() if not records: if self.one_time_price: br = BillRecord.objects.create(bill=bill, order=self, starting_date=self.starting_date, ending_date=bill.ending_date, is_recurring_record=False) else: br = self.create_new_bill_record_for_recurring_order(bill) else: opened_recurring_record = BillRecord.objects.filter(bill=bill, order=self, is_recurring_record=True).first() if opened_recurring_record: br = opened_recurring_record 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.ending_date != self.ending_date: bill_record.ending_date = self.ending_date else: # recurring, not terminated, should go until at least end of bill if bill_record.ending_date < bill.ending_date: bill_record.ending_date = bill.ending_date bill_record.save() def create_new_bill_record_for_recurring_order(self, bill): """ Create a new bill record """ last_bill_record = BillRecord.objects.filter(order=self).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_recurring_price(self): try: config = json.loads(self.config) recurring_price = self.pricing_plan.monthly_maintenance_fees if 'cores' in config: recurring_price += self.pricing_plan.cores_unit_price * int(config['cores']) if 'memory' in config: recurring_price += self.pricing_plan.ram_unit_price * int(config['memory']) if 'storage' in config: #TODO Fix the ssd static value recurring_price += (10 * self.pricing_plan.storage_ssd_unit_price) + (self.pricing_plan.storage_hd_unit_price * int(config['storage'])) vat_rate = VATRate.get_vat_rate(self.billing_address) vat_validation_status = "verified" if self.billing_address.vat_number_validated_on and self.billing_address.vat_number_verified else False subtotal, subtotal_after_discount, price_after_discount_with_vat, vat, vat_percent, vat_amount, discount = uncloud_pay.utils.apply_vat_discount( recurring_price, self.pricing_plan, vat_rate=vat_rate * 100, vat_validation_status = vat_validation_status ) return price_after_discount_with_vat except Exception as e: logger.error("An error occurred while parsing the config obj", e) return 0 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.recurring_price = self.calculate_recurring_price() 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): return f"Order {self.id}: {self.description}" 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) currency = models.CharField(max_length=32, choices=Currency.choices, default=Currency.CHF) # FIXME: editable=True -> is in the admin, but also editable in DRF # Maybe filter fields in the serializer? status = models.CharField(max_length=32, choices= ( ('new', 'New'), ('cancelled', 'Cancelled'), ('paid', 'Paid') ), null=False, blank=False, default="new") class Meta: constraints = [ models.UniqueConstraint(fields=['owner', 'starting_date', 'ending_date' ], name='one_bill_per_month_per_user') ] def close(self, status): """ Close/finish a bill """ self.status = status if not self.ending_date: self.ending_date = timezone.now() self.save() @property def sum(self): bill_records = BillRecord.objects.filter(bill=self) return sum([ br.sum for br in bill_records ]) @property def subtotal(self): bill_records = BillRecord.objects.filter(bill=self) return sum([ br.subtotal for br in bill_records ]) @property def vat_amount(self): return round(self.vat_rate * self.subtotal, 2) @property def vat_rate(self): return VATRate.get_vat_rate(self.billing_address, when=self.ending_date) @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): bill = cls.create_next_bill_for_user_address(billing_address, ending_date) if bill: bills.append(bill) 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(Q(owner__id=owner.id), Q(should_be_billed=True), Q(billing_address__id=billing_address.id) ).order_by('id') if len(all_orders) > 0: bill = cls.get_or_create_bill(billing_address, ending_date=ending_date) for order in all_orders: order.create_bill_record(bill) return bill else: # This Customer Hasn't any active orders return False @classmethod def create_next_bill_for_order(cls, order, ending_date=None): """ Create the next bill for a specific order of a user """ bill = cls.get_or_create_bill(order.billing_address, ending_date=ending_date) 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 last_bill.status == 'new': 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"{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, related_name='bill_records') 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.date() - self.starting_date.date() if self.order.recurring_period and self.order.recurring_period.duration_seconds > 0: return int(record_delta.total_seconds() / self.order.recurring_period.duration_seconds) else: return 1 @property def sum(self): if self.is_recurring_record: return round(float(self.order.recurring_price) * self.quantity, 2) else: return self.order.one_time_price @property def description(self): if self.order: return self.order.description return '' @property def price(self): if self.is_recurring_record: return self.order.recurring_price else: return self.order.one_time_price @property def subtotal(self): billing_address_ins = self.order.billing_address vat_rate = VATRate.get_vat_rate(billing_address_ins) vat_validation_status = "verified" if billing_address_ins.vat_number_validated_on and billing_address_ins.vat_number_verified else False config = json.loads(self.order.config) pricing = uncloud_pay.utils.get_order_total_with_vat( config["cores"], config["memory"], config["storage"], self.order.pricing_plan.name, vat_rate=vat_rate * 100, vat_validation_status = vat_validation_status ) return pricing['subtotal_after_discount'] 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=models.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})" class Membership(models.Model): owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE) starting_date = models.DateField(blank=True, null=True) ending_date = models.DateField(blank=True, null=True) @classmethod def user_has_membership(user, when): """ Return true if user has membership at a point of time, return false if that is not the case """ pass # cls.objects.filter(owner=user, # starting_date)