Add JSON support for product description
This commit is contained in:
parent
c6bacab35a
commit
c32499199a
10 changed files with 1589 additions and 653 deletions
|
|
@ -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):
|
||||
"""
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue