From 5d1eaaf0af234d0e99b48a7a69cbbccad05823dc Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 24 May 2020 12:46:11 +0200 Subject: [PATCH] Add new models backup - before major refactoring --- uncloud_pay/models_prior_to_cleanup.py | 1003 ++++++++++++++++++++++++ 1 file changed, 1003 insertions(+) create mode 100644 uncloud_pay/models_prior_to_cleanup.py diff --git a/uncloud_pay/models_prior_to_cleanup.py b/uncloud_pay/models_prior_to_cleanup.py new file mode 100644 index 0000000..55ccffb --- /dev/null +++ b/uncloud_pay/models_prior_to_cleanup.py @@ -0,0 +1,1003 @@ +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 + +import uuid +import logging +from functools import reduce +import itertools +from math import ceil +from datetime import timedelta +from calendar import monthrange +from decimal import Decimal + +import uncloud_pay.stripe +from uncloud_pay.helpers import beginning_of_month, end_of_month +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=timedelta(days=10) + +# Initialize logger. +logger = logging.getLogger(__name__) + +# See https://docs.djangoproject.com/en/dev/ref/models/fields/#field-choices-enum-types +class RecurringPeriod(models.IntegerChoices): + 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') + + +class CountryField(models.CharField): + def __init__(self, *args, **kwargs): + kwargs.setdefault('choices', COUNTRIES) + kwargs.setdefault('default', 'CH') + kwargs.setdefault('max_length', 2) + + super(CountryField, self).__init__(*args, **kwargs) + + 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 + +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): + uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + + 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): + uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + 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 + +### +# Bills. + +class BillingAddress(models.Model): + uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE) + + organization = models.CharField(max_length=100) + 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) + + @staticmethod + def get_addresses_for(user): + return BillingAddress.objects.filter(owner=user) + + @classmethod + def get_preferred_address_for(cls, user): + addresses = cls.get_addresses_for(user) + if len(addresses) == 0: + return None + else: + # TODO: allow user to set primary/preferred address + return addresses[0] + + def __str__(self): + return "{}, {}, {} {}, {}".format( + self.name, self.street, self.postal_code, self.city, + self.country) + +# Populated with the import-vat-numbers django command. +class VATRate(models.Model): + start_date = models.DateField(blank=True, null=True) + stop_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 + +class BillNico(models.Model): + """ FIXME: + Bill needs to be unique in the triple (owner, year, month) + """ + + uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + owner = models.ForeignKey(get_user_model(), + on_delete=models.CASCADE) + + creation_date = models.DateTimeField(auto_now_add=True) + starting_date = models.DateTimeField() + ending_date = models.DateTimeField() + due_date = models.DateField() + + valid = models.BooleanField(default=True) + + @staticmethod + def create_all_bills(): + for owner in get_user_model().objects.all(): + # mintime = time of first order + # maxtime = time of last order + # iterate month based through it + pass + + def assign_orders_to_bill(self, owner, year, month): + """ + Generate a bill for the specific month of a user. + + First handle all one time orders + + FIXME: + + - limit this to active users in the future! (2020-05-23) + """ + + """ + Find all one time orders that have a starting date that falls into this month + recurring_period=RecurringPeriod.ONE_TIME, + + Can we do this even for recurring / all of them + + """ + + # FIXME: add something to check whether the order should be billed at all - i.e. a marker that + # disables searching -> optimization for later + for order in Order.objects.filter(Q(starting_date__gte=self.starting_date), + Q(starting_date__lte=self.ending_date), + owner=owner): + + order.bill.add(self) + + + """ + Find all recurring orders that did not start in this time frame, but need + to be billed in this time frame. + + This is: + - order starting time before our starting time + - order start time + (x * (the_period)) is inside our time frame, x must be integer + test cases: + + 365days: + time_since_last_billed = self.starting_or_ending_date - order.last_bill_date + periods = + [ we could in theory add this as a property to the order: next + """ + for order in Order.objects.filter(~Q(recurring_period=RecurringPeriod.ONE_TIME), + Q(starting_date__lt=self.starting_date), + owner=owner): + + if order.recurring_period > 0: # avoid div/0 - these are one time payments + + # How much time will have passed by the end of the billing cycle + td = self.ending_date - order.starting_date + + # How MANY times it will have been used by then + used_times = ceil(td / timedelta(seconds=order.recurring_period)) + + billed_times = len(order.bills) + + # How many times it WAS billed -- can also be inferred from the bills that link to it! + if used_times > billed_times: + billing_times = used_times - billed_times + + # ALSO REGISTER THE TIME PERIOD! +# order. + pass + + +class Bill(models.Model): + uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + owner = models.ForeignKey(get_user_model(), + on_delete=models.CASCADE) + + creation_date = models.DateTimeField(auto_now_add=True) + starting_date = models.DateTimeField() + ending_date = models.DateTimeField() + due_date = models.DateField() + + valid = models.BooleanField(default=True) + + # Trigger product activation if bill paid at creation (from balance). + def save(self, *args, **kwargs): + super(Bill, self).save(*args, **kwargs) + if not self in Bill.get_unpaid_for(self.owner): + self.activate_products() + + @property + def reference(self): + return "{}-{}".format( + self.owner.username, + self.creation_date.strftime("%Y-%m-%d-%H%M")) + + @property + def records(self): + bill_records = [] + orders = Order.objects.filter(bill=self) + for order in orders: + bill_record = BillRecord(self, order) + bill_records.append(bill_record) + + return bill_records + + @property + def amount(self): + return reduce(lambda acc, record: acc + record.amount, self.records, 0) + + @property + def vat_amount(self): + return reduce(lambda acc, record: acc + record.vat_amount, self.records, 0) + + @property + def total(self): + return self.amount + self.vat_amount + + @property + def final(self): + # A bill is final when its ending date is passed, or when all of its + # orders have been terminated. + every_order_terminated = True + billing_period_is_over = self.ending_date < timezone.now() + for order in self.order_set.all(): + every_order_terminated = every_order_terminated and order.is_terminated + + return billing_period_is_over or every_order_terminated + + def activate_products(self): + for order in self.order_set.all(): + # FIXME: using __something might not be a good idea. + for product_class in Product.__subclasses__(): + for product in product_class.objects.filter(order=order): + if product.status == UncloudStatus.AWAITING_PAYMENT: + product.status = UncloudStatus.PENDING + product.save() + + @property + def billing_address(self): + orders = Order.objects.filter(bill=self) + # The genrate_for method makes sure all the orders of a bill share the + # same billing address. TODO: It would be nice to enforce that somehow... + if orders: + return orders[0].billing_address + else: + return None + + # TODO: split this huuuge method! + @staticmethod + def generate_for(year, month, user): + # /!\ We exclusively work on the specified year and month. + generated_bills = [] + + # Default values for next bill (if any). + starting_date=beginning_of_month(year, month) + ending_date=end_of_month(year, month) + creation_date=timezone.now() + + # Select all orders active on the request period (i.e. starting on or after starting_date). + orders = Order.objects.filter( + Q(ending_date__gte=starting_date) | Q(ending_date__isnull=True), + owner=user) + + # Check if there is already a bill covering the order and period pair: + # * Get latest bill by ending_date: previous_bill.ending_date + # * For monthly bills: if previous_bill.ending_date is before + # (next_bill) ending_date, a new bill has to be generated. + # * For yearly bill: if previous_bill.ending_date is on working + # month, generate new bill. + unpaid_orders = { 'monthly_or_less': [], 'yearly': {} } + for order in orders: + try: + previous_bill = order.bill.latest('ending_date') + except ObjectDoesNotExist: + previous_bill = None + + # FIXME: control flow is confusing in this block. + # if order.recurring_period == RecurringPeriod.PER_YEAR: + # # We ignore anything smaller than a day in here. + # next_yearly_bill_start_on = None + # if previous_bill == None: + # next_yearly_bill_start_on = order.starting_date + # elif previous_bill.ending_date <= ending_date: + # next_yearly_bill_start_on = (previous_bill.ending_date + timedelta(days=1)) + + # # Store for bill generation. One bucket per day of month with a starting bill. + # # bucket is a reference here, no need to reassign. + # if next_yearly_bill_start_on: + # # We want to group orders by date but keep using datetimes. + # next_yearly_bill_start_on = next_yearly_bill_start_on.replace( + # minute=0, hour=0, second=0, microsecond=0) + # bucket = unpaid_orders['yearly'].get(next_yearly_bill_start_on) + # if bucket == None: + # unpaid_orders['yearly'][next_yearly_bill_start_on] = [order] + # else: + # unpaid_orders['yearly'][next_yearly_bill_start_on] = bucket + [order] + # else: + # if previous_bill == None or previous_bill.ending_date < ending_date: + # unpaid_orders['monthly_or_less'].append(order) + + # Handle working month's billing. + if len(unpaid_orders['monthly_or_less']) > 0: + # TODO: PREPAID billing is not supported yet. + prepaid_due_date = min(creation_date, starting_date) + BILL_PAYMENT_DELAY + postpaid_due_date = max(creation_date, ending_date) + BILL_PAYMENT_DELAY + + # There should not be any bill linked to orders with different + # billing addresses. + per_address_orders = itertools.groupby( + unpaid_orders['monthly_or_less'], + lambda o: o.billing_address) + + for addr, bill_orders in per_address_orders: + next_monthly_bill = Bill.objects.create(owner=user, + creation_date=creation_date, + starting_date=starting_date, # FIXME: this is a hack! + ending_date=ending_date, + due_date=postpaid_due_date) + + # It is not possible to register many-to-many relationship before + # the two end-objects are saved in database. + for order in bill_orders: + order.bill.add(next_monthly_bill) + + logger.info("Generated monthly bill {} (amount: {}) for user {}." + .format(next_monthly_bill.uuid, next_monthly_bill.total, user)) + + # Add to output. + generated_bills.append(next_monthly_bill) + + # Handle yearly bills starting on working month. + if len(unpaid_orders['yearly']) > 0: + # For every starting date, generate new bill. + for next_yearly_bill_start_on in unpaid_orders['yearly']: + # No postpaid for yearly payments. + prepaid_due_date = min(creation_date, next_yearly_bill_start_on) + BILL_PAYMENT_DELAY + # Bump by one year, remove one day. + ending_date = next_yearly_bill_start_on.replace( + year=next_yearly_bill_start_on.year+1) - timedelta(days=1) + + # There should not be any bill linked to orders with different + # billing addresses. + per_address_orders = itertools.groupby( + unpaid_orders['yearly'][next_yearly_bill_start_on], + lambda o: o.billing_address) + + for addr, bill_orders in per_address_orders: + next_yearly_bill = Bill.objects.create(owner=user, + creation_date=creation_date, + starting_date=next_yearly_bill_start_on, + ending_date=ending_date, + due_date=prepaid_due_date) + + # It is not possible to register many-to-many relationship before + # the two end-objects are saved in database. + for order in bill_orders: + order.bill.add(next_yearly_bill) + + logger.info("Generated yearly bill {} (amount: {}) for user {}." + .format(next_yearly_bill.uuid, next_yearly_bill.total, user)) + + # Add to output. + generated_bills.append(next_yearly_bill) + + # Return generated (monthly + yearly) bills. + return generated_bills + + @staticmethod + def get_unpaid_for(user): + balance = get_balance_for_user(user) + unpaid_bills = [] + # No unpaid bill if balance is positive. + if balance >= 0: + return unpaid_bills + else: + bills = Bill.objects.filter( + owner=user, + ).order_by('-creation_date') + + # Amount to be paid by the customer. + unpaid_balance = abs(balance) + for bill in bills: + if unpaid_balance <= 0: + break + + unpaid_balance -= bill.total + unpaid_bills.append(bill) + + return unpaid_bills + + @staticmethod + def get_overdue_for(user): + unpaid_bills = Bill.get_unpaid_for(user) + return list(filter(lambda bill: bill.due_date > timezone.now(), unpaid_bills)) + +class BillRecord(): + """ + Entry of a bill, dynamically generated from an order. + """ + + def __init__(self, bill, order): + self.bill = bill + self.order = order + self.recurring_price = order.recurring_price + self.recurring_period = order.recurring_period + self.description = order.description + + if self.order.starting_date >= self.bill.starting_date: + self.one_time_price = order.one_time_price + else: + self.one_time_price = 0 + + # Set decimal context for amount computations. + # XXX: understand why we need +1 here. + decimal.getcontext().prec = AMOUNT_DECIMALS + 1 + + @property + def recurring_count(self): + # Compute billing delta. + billed_until = self.bill.ending_date + if self.order.ending_date != None and self.order.ending_date <= self.bill.ending_date: + billed_until = self.order.ending_date + + billed_from = self.bill.starting_date + if self.order.starting_date > self.bill.starting_date: + billed_from = self.order.starting_date + + if billed_from > billed_until: + # TODO: think about and check edge cases. This should not be + # possible. + raise Exception('Impossible billing delta!') + + billed_delta = billed_until - billed_from + + # TODO: refactor this thing? + # TODO: weekly + # if self.recurring_period == RecurringPeriod.PER_YEAR: + # # XXX: Should always be one => we do not bill for more than one year. + # # TODO: check billed_delta is ~365 days. + # return 1 + # elif self.recurring_period == RecurringPeriod.PER_MONTH: + # days = ceil(billed_delta / timedelta(days=1)) + + # # Monthly bills always cover one single month. + # if (self.bill.starting_date.year != self.bill.starting_date.year or + # self.bill.starting_date.month != self.bill.ending_date.month): + # raise Exception('Bill {} covers more than one month. Cannot bill PER_MONTH.'. + # format(self.bill.uuid)) + + # # XXX: minumal length of monthly order is to be enforced somewhere else. + # (_, days_in_month) = monthrange( + # self.bill.starting_date.year, + # self.bill.starting_date.month) + # return round(days / days_in_month, AMOUNT_DECIMALS) + if self.recurring_period == RecurringPeriod.PER_WEEK: + weeks = ceil(billed_delta / timedelta(week=1)) + return weeks + elif self.recurring_period == RecurringPeriod.PER_DAY: + days = ceil(billed_delta / timedelta(days=1)) + return days + elif self.recurring_period == RecurringPeriod.PER_HOUR: + hours = ceil(billed_delta / timedelta(hours=1)) + return hours + elif self.recurring_period == RecurringPeriod.PER_SECOND: + seconds = ceil(billed_delta / timedelta(seconds=1)) + return seconds + elif self.recurring_period == RecurringPeriod.ONE_TIME: + return 0 + else: + raise Exception('Unsupported recurring period: {}.'. + format(self.order.recurring_period)) + + @property + def vat_rate(self): + return Decimal(VATRate.get_for_country(self.bill.billing_address.country)) + + @property + def vat_amount(self): + return self.amount * self.vat_rate + + @property + def amount(self): + return Decimal(float(self.recurring_price) * self.recurring_count) + self.one_time_price + + @property + def total(self): + return self.amount + self.vat_amount + +### +# Orders. + +# Order are assumed IMMUTABLE and used as SOURCE OF TRUST for generating +# bills. Do **NOT** mutate then! +class Order(models.Model): + uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + owner = models.ForeignKey(get_user_model(), + on_delete=models.CASCADE, + editable=False) + billing_address = models.ForeignKey(BillingAddress, on_delete=models.CASCADE) + description = models.TextField() + replaced_by = models.ForeignKey('self', on_delete=models.CASCADE, blank=True, null=True) + + # TODO: enforce ending_date - starting_date to be larger than recurring_period. + creation_date = models.DateTimeField(auto_now_add=True) + starting_date = models.DateTimeField(default=timezone.now) + ending_date = models.DateTimeField(blank=True, + null=True) + + bill = models.ManyToManyField(Bill, + editable=False, + blank=True) + + recurring_period = models.IntegerField(choices = RecurringPeriod.choices, default = RecurringPeriod.PER_30D) + + 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)]) + + replaced_by = models.ForeignKey('self', + related_name='supersede', + on_delete=models.PROTECT, + blank=True, + null=True) + + depends_on = models.ForeignKey('self', + related_name='parent_of', + on_delete=models.PROTECT, + blank=True, + null=True) + + def active_before(self, ending_date): + # Was this order started before the specified ending date? + if self.starting_date <= ending_date: + if self.ending_date: + if self.ending_date > ending_date: + pass + + @property + def is_recurring(self): + return not self.recurring_period == RecurringPeriod.ONE_TIME + + @property + def is_terminated(self): + return self.ending_date != None and self.ending_date < timezone.now() + + def is_terminated_at(self, a_date): + return self.ending_date != None and self.ending_date < timezone.now() + + def terminate(self): + if not self.is_terminated: + self.ending_date = timezone.now() + self.save() + + def is_to_be_charged_in(year, month): + pass + + # Trigger initial bill generation at order creation. + def save(self, *args, **kwargs): + if self.ending_date and self.ending_date < self.starting_date: + raise ValidationError("End date cannot be before starting date") + + super().save(*args, **kwargs) + + def generate_initial_bill(self): + return Bill.generate_for(self.starting_date.year, self.starting_date.month, self.owner) + + # Used by uncloud_pay tests. + @property + def bills(self): + return Bill.objects.filter(order=self) + + @staticmethod + def from_product(product, **kwargs): + # FIXME: this is only a workaround. + billing_address = BillingAddress.get_preferred_address_for(product.owner) + if billing_address == None: + raise Exception("Owner does not have a billing address!") + + return Order(description=product.description, + one_time_price=product.one_time_price, + recurring_price=product.recurring_price, + billing_address=billing_address, + owner=product.owner, + **kwargs) + + def __str__(self): + return "Order {} created at {}, {}->{}, recurring period {}. One time price {}, recurring price {}".format( + self.uuid, self.creation_date, + self.starting_date, self.ending_date, + self.recurring_period, + self.one_time_price, + self.recurring_price) + +class OrderTimothee(models.Model): + uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + owner = models.ForeignKey(get_user_model(), + on_delete=models.CASCADE, + editable=False) + billing_address = models.ForeignKey(BillingAddress, on_delete=models.CASCADE) + + # TODO: enforce ending_date - starting_date to be larger than recurring_period. + creation_date = models.DateTimeField(auto_now_add=True) + starting_date = models.DateTimeField(default=timezone.now) + ending_date = models.DateTimeField(blank=True, + null=True) + + bill = models.ManyToManyField(Bill, + editable=False, + blank=True) + + recurring_period = models.IntegerField(choices = RecurringPeriod.choices, + default = RecurringPeriod.PER_30D) + + # Trigger initial bill generation at order creation. + def save(self, *args, **kwargs): + if self.ending_date and self.ending_date < self.starting_date: + raise ValidationError("End date cannot be before starting date") + + super().save(*args, **kwargs) + + Bill.generate_for(self.starting_date.year, self.starting_date.month, self.owner) + + @property + def records(self): + return OrderRecord.objects.filter(order=self) + + @property + def one_time_price(self): + return reduce(lambda acc, record: acc + record.one_time_price, self.records, 0) + + @property + def recurring_price(self): + return reduce(lambda acc, record: acc + record.recurring_price, self.records, 0) + + # Used by uncloud_pay tests. + @property + def bills(self): + return Bill.objects.filter(order=self) + + def add_record(self, one_time_price, recurring_price, description): + OrderRecord.objects.create(order=self, + one_time_price=one_time_price, + recurring_price=recurring_price, + description=description) + + def __str__(self): + return "Order {} created at {}, {}->{}, recurring period {}. Price one time {}, recurring {}".format( + self.uuid, self.creation_date, + self.starting_date, self.ending_date, + self.recurring_period, + self.one_time_price, + self.recurring_price) + + + +class OrderRecord(models.Model): + """ + Order records store billing informations for products: the actual product + might be mutated and/or moved to another order but we do not want to loose + the details of old orders. + + Used as source of trust to dynamically generate bill entries. + """ + + order = models.ForeignKey(Order, on_delete=models.CASCADE) + 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)]) + + description = models.TextField() + + + @property + def recurring_period(self): + return self.order.recurring_period + + @property + def starting_date(self): + return self.order.starting_date + + @property + def ending_date(self): + return self.order.ending_date + + +### +# Products + +# Abstract (= no database representation) class used as parent for products +# (e.g. uncloud_vm.models.VMProduct). +class Product(UncloudModel): + uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + owner = models.ForeignKey(get_user_model(), + on_delete=models.CASCADE, + editable=False) + + description = "Generic Product" + + status = models.CharField(max_length=32, + choices=UncloudStatus.choices, + default=UncloudStatus.AWAITING_PAYMENT) + + order = models.ForeignKey(Order, + on_delete=models.CASCADE, + editable=False, + null=True) + + # Default period for all products + default_recurring_period = RecurringPeriod.PER_30D + + # Used to save records. + def save(self, *args, **kwargs): + # _state.adding is switched to false after super(...) call. + being_created = self._state.adding + + # First time saving - create an order + if not self.order: + billing_address = BillingAddress.get_preferred_address_for(self.owner) + print(billing_address) + + if not billing_address: + raise ValidationError("Cannot order without a billing address") + + # FIXME: allow user to choose recurring_period + self.order = Order.objects.create(owner=self.owner, + billing_address=billing_address, + one_time_price=self.one_time_price, + recurring_period=self.default_recurring_period, + recurring_price=self.recurring_price) + + super().save(*args, **kwargs) + + # # Make sure we only create records on creation. + # if being_created: + # record = OrderRecord( + # one_time_price=self.one_time_price, + # recurring_price=self.recurring_price, + # description=self.description) + # self.order.orderrecord_set.add(record, bulk=False) + + @property + def recurring_price(self): + pass # To be implemented in child. + + @property + def one_time_price(self): + return 0 + + @property + def billing_address(self): + return self.order.billing_address + + @staticmethod + def allowed_recurring_periods(): + return RecurringPeriod.choices + + class Meta: + abstract = True + + 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) + + """ + + + 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")