uncloud/uncloud_pay/models.py

1260 lines
46 KiB
Python

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
from django_q.tasks import schedule
from django_q.models import Schedule
# 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)
# 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_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
@staticmethod
def get_vat_rate(billing_address, when=None):
"""
Returns the VAT rate for business to customer.
B2B is always 0% with the exception of trading within the own country
"""
country = billing_address.country
# Need to have a provider country
providers = UncloudProvider.objects.all()
vatrate = filter_for_when(VATRate.objects.filter(territory_codes=country), when).first()
if not providers and not vatrate:
return 0
uncloud_provider = filter_for_when(providers).get()
# By default we charge VAT. This affects:
# - Same country sales (VAT applied)
# - B2C to EU (VAT applied)
rate = vatrate.rate if vatrate else 0
# 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 uncloud_provider.country != country 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} - {self.description}"
@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
)
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_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_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()
if hasattr(self, 'instance_id'):
last_bill_record = BillRecord.objects.filter(order=self).order_by('id').last()
schedule('matrixhosting.tasks.delete_instance',
self.instance_id,
schedule_type=Schedule.ONCE,
next_run=last_bill_record.ending_date or (timezone.now() + datetime.timedelta(hours=1)))
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 = 0
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:
recurring_price += self.pricing_plan.storage_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?
is_closed = models.BooleanField(default=False)
class Meta:
constraints = [
models.UniqueConstraint(fields=['owner',
'starting_date',
'ending_date' ],
name='one_bill_per_month_per_user')
]
def close(self):
"""
Close/finish a bill
"""
self.is_closed = True
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 not last_bill.is_closed:
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 self.order.recurring_price * Decimal(self.quantity)
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)