Add JSON support for product description

This commit is contained in:
Nico Schottelius 2020-09-28 21:34:24 +02:00
commit c32499199a
10 changed files with 1589 additions and 653 deletions

View file

@ -80,6 +80,16 @@ class RecurringPeriod(models.IntegerChoices):
PER_SECOND = 1, _('Per Second')
ONE_TIME = 0, _('Onetime')
class Currency(models.TextChoices):
"""
We don't support months are years, because they vary in length.
This is not only complicated, but also unfair to the user, as the user pays the same
amount for different durations.
"""
CHF = 'CHF', _('Swiss Franc')
EUR = 'EUR', _('Euro')
USD = 'USD', _('US Dollar')
class CountryField(models.CharField):
def __init__(self, *args, **kwargs):
kwargs.setdefault('choices', COUNTRIES)
@ -283,6 +293,235 @@ class VATRate(models.Model):
logger.debug("Did not find VAT rate for %s, returning 0" % country_code)
return 0
###
# Products
class Product(UncloudModel):
"""
A product is something a user can order. To record the pricing, we
create order that define a state in time.
A product can have *one* one_time_order and/or *one*
recurring_order.
If either of them needs to be updated, a new order of the same
type will be created and links to the previous order.
"""
name = models.CharField(max_length=256)
description = models.CharField(max_length=1024)
config = models.JSONField()
default_recurring_period = models.IntegerField(choices=RecurringPeriod.choices, default=RecurringPeriod.PER_30D)
currency = models.CharField(max_length=32, choices=Currency.choices, default=Currency.CHF)
@property
def recurring_orders(self):
return self.orders.order_by('id').exclude(recurring_period=RecurringPeriod.ONE_TIME)
@property
def last_recurring_order(self):
return self.recurring_orders.last()
@property
def one_time_orders(self):
return self.orders.order_by('id').filter(recurring_period=RecurringPeriod.ONE_TIME)
@property
def last_one_time_order(self):
return self.one_time_orders.last()
def create_order(self, when_to_start=None, recurring_period=None):
billing_address = BillingAddress.get_address_for(self.owner)
if not billing_address:
raise ValidationError("Cannot order without a billing address")
if not when_to_start:
when_to_start = timezone.now()
if not recurring_period:
recurring_period = self.default_recurring_period
# Create one time order if we did not create one already
if self.one_time_price > 0 and not self.last_one_time_order:
one_time_order = Order.objects.create(owner=self.owner,
billing_address=billing_address,
starting_date=when_to_start,
price=self.one_time_price,
recurring_period=RecurringPeriod.ONE_TIME,
description=str(self))
self.orders.add(one_time_order)
else:
one_time_order = None
if recurring_period != RecurringPeriod.ONE_TIME:
if one_time_order:
recurring_order = Order.objects.create(owner=self.owner,
billing_address=billing_address,
starting_date=when_to_start,
price=self.recurring_price,
recurring_period=recurring_period,
depends_on=one_time_order,
description=str(self))
else:
recurring_order = Order.objects.create(owner=self.owner,
billing_address=billing_address,
starting_date=when_to_start,
price=self.recurring_price,
recurring_period=recurring_period,
description=str(self))
self.orders.add(recurring_order)
# FIXME: this could/should be part of Order (?)
def create_or_update_recurring_order(self, when_to_start=None, recurring_period=None):
if not self.recurring_price:
return
if not recurring_period:
recurring_period = self.default_recurring_period
if not when_to_start:
when_to_start = timezone.now()
if self.last_recurring_order:
if self.recurring_price < self.last_recurring_order.price:
if when_to_start < self.last_recurring_order.next_cancel_or_downgrade_date:
when_to_start = start_after(self.last_recurring_order.next_cancel_or_downgrade_date)
when_to_end = end_before(when_to_start)
new_order = Order.objects.create(owner=self.owner,
billing_address=self.last_recurring_order.billing_address,
starting_date=when_to_start,
price=self.recurring_price,
recurring_period=recurring_period,
description=str(self),
replaces=self.last_recurring_order)
self.last_recurring_order.replace_with(new_order)
self.orders.add(new_order)
else:
self.create_order(when_to_start, recurring_period)
@property
def recurring_price(self):
""" implement correct values in the child class """
return 0
@property
def one_time_price(self):
""" implement correct values in the child class """
return 0
@property
def is_recurring(self):
return self.recurring_price > 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")
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.
@ -317,7 +556,9 @@ class Order(models.Model):
description = models.TextField()
# TODO: enforce ending_date - starting_date to be larger than recurring_period.
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)
@ -850,237 +1091,6 @@ class BillRecord(models.Model):
super().save(*args, **kwargs)
###
# Products
class Product(UncloudModel):
"""
A product is something a user can order. To record the pricing, we
create order that define a state in time.
A product can have *one* one_time_order and/or *one*
recurring_order.
If either of them needs to be updated, a new order of the same
type will be created and links to the previous order.
"""
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)
config = models.JSONField()
# Default period for all products
default_recurring_period = RecurringPeriod.PER_30D
@property
def recurring_orders(self):
return self.orders.order_by('id').exclude(recurring_period=RecurringPeriod.ONE_TIME)
@property
def last_recurring_order(self):
return self.recurring_orders.last()
@property
def one_time_orders(self):
return self.orders.order_by('id').filter(recurring_period=RecurringPeriod.ONE_TIME)
@property
def last_one_time_order(self):
return self.one_time_orders.last()
def create_order(self, when_to_start=None, recurring_period=None):
billing_address = BillingAddress.get_address_for(self.owner)
if not billing_address:
raise ValidationError("Cannot order without a billing address")
if not when_to_start:
when_to_start = timezone.now()
if not recurring_period:
recurring_period = self.default_recurring_period
# Create one time order if we did not create one already
if self.one_time_price > 0 and not self.last_one_time_order:
one_time_order = Order.objects.create(owner=self.owner,
billing_address=billing_address,
starting_date=when_to_start,
price=self.one_time_price,
recurring_period=RecurringPeriod.ONE_TIME,
description=str(self))
self.orders.add(one_time_order)
else:
one_time_order = None
if recurring_period != RecurringPeriod.ONE_TIME:
if one_time_order:
recurring_order = Order.objects.create(owner=self.owner,
billing_address=billing_address,
starting_date=when_to_start,
price=self.recurring_price,
recurring_period=recurring_period,
depends_on=one_time_order,
description=str(self))
else:
recurring_order = Order.objects.create(owner=self.owner,
billing_address=billing_address,
starting_date=when_to_start,
price=self.recurring_price,
recurring_period=recurring_period,
description=str(self))
self.orders.add(recurring_order)
# FIXME: this could/should be part of Order (?)
def create_or_update_recurring_order(self, when_to_start=None, recurring_period=None):
if not self.recurring_price:
return
if not recurring_period:
recurring_period = self.default_recurring_period
if not when_to_start:
when_to_start = timezone.now()
if self.last_recurring_order:
if self.recurring_price < self.last_recurring_order.price:
if when_to_start < self.last_recurring_order.next_cancel_or_downgrade_date:
when_to_start = start_after(self.last_recurring_order.next_cancel_or_downgrade_date)
when_to_end = end_before(when_to_start)
new_order = Order.objects.create(owner=self.owner,
billing_address=self.last_recurring_order.billing_address,
starting_date=when_to_start,
price=self.recurring_price,
recurring_period=recurring_period,
description=str(self),
replaces=self.last_recurring_order)
self.last_recurring_order.replace_with(new_order)
self.orders.add(new_order)
else:
self.create_order(when_to_start, recurring_period)
@property
def recurring_price(self):
""" implement correct values in the child class """
return 0
@property
def one_time_price(self):
""" implement correct values in the child class """
return 0
@property
def is_recurring(self):
return self.recurring_price > 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")
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")
super().save(*args, **kwargs)
# Sample products included into uncloud
class SampleOneTimeProduct(models.Model):
"""