- Added PricingPlan Model

- Implement a complete cycle for buying a Matrix Chat Host
- Refactor the Payement cycle and stripe related methods
This commit is contained in:
amalelshihaby 2021-07-19 16:36:10 +02:00 committed by Nico Schottelius
commit b7aa1c6971
81 changed files with 5079 additions and 810 deletions

View file

@ -1,5 +1,6 @@
import logging
import datetime
import json
from math import ceil
from calendar import monthrange
@ -9,18 +10,22 @@ 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
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=10)
BILL_PAYMENT_DELAY=datetime.timedelta(days=settings.BILL_PAYMENT_DELAY)
# Initialize logger.
logger = logging.getLogger(__name__)
@ -96,84 +101,18 @@ class Payment(models.Model):
def __str__(self):
return f"{self.amount}{self.currency} from {self.owner} via {self.source} on {self.timestamp}"
###
# Payments and Payment Methods.
class PaymentMethod(models.Model):
"""
Not sure if this is still in use
"""
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 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.')
def save(self, *args, **kwargs):
# Try to charge the user via the active card before saving otherwise throw payment Error
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
try:
result = uncloud_pay.stripe.charge_customer(self.owner, self.amount, self.currency,)
if not result.status or result.status != 'succeeded':
raise Exception("The payment has been failed, please try to activate another card")
super().save(*args, **kwargs)
except Exception as e:
raise e
# See https://docs.djangoproject.com/en/dev/ref/models/fields/#field-choices-enum-types
class RecurringPeriodDefaultChoices(models.IntegerChoices):
@ -231,9 +170,11 @@ class RecurringPeriod(models.Model):
# Bills.
class BillingAddress(UncloudAddress):
owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE)
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:
@ -272,6 +213,10 @@ class BillingAddress(UncloudAddress):
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
@ -297,10 +242,44 @@ class VATRate(models.Model):
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}: {self.rate_type}"
return f"{self.territory_codes}: {self.starting_date} - {self.ending_date or ''}: {self.rate_type}"
###
# Products
@ -342,30 +321,20 @@ class Product(models.Model):
'features': {
'cores':
{ 'min': 1,
'max': 48,
'one_time_price_per_unit': 0,
'recurring_price_per_unit': 3
'max': 48
},
'ram_gb':
{ 'min': 1,
'max': 256,
'one_time_price_per_unit': 0,
'recurring_price_per_unit': 4
'max': 256
},
'ssd_gb':
{ 'min': 10,
'one_time_price_per_unit': 0,
'recurring_price_per_unit': 0.35
{ 'min': 10
},
'hdd_gb':
{ 'min': 0,
'one_time_price_per_unit': 0,
'recurring_price_per_unit': 15/1000
},
'additional_ipv4_address':
{ 'min': 0,
'one_time_price_per_unit': 0,
'recurring_price_per_unit': 8
},
}
}
@ -381,36 +350,23 @@ class Product(models.Model):
'base':
{ 'min': 1,
'max': 1,
'one_time_price_per_unit': 0,
'recurring_price_per_unit': 1
},
'cores':
{ 'min': 1,
'max': 48,
'one_time_price_per_unit': 0,
'recurring_price_per_unit': 3
},
'ram_gb':
{ 'min': 1,
'max': 256,
'one_time_price_per_unit': 0,
'recurring_price_per_unit': 4
},
'ssd_gb':
{ 'min': 10,
'one_time_price_per_unit': 0,
'recurring_price_per_unit': 0.35
{ 'min': 10
},
'hdd_gb':
{ 'min': 0,
'one_time_price_per_unit': 0,
'recurring_price_per_unit': 15/1000
{ 'min': 0
},
'additional_ipv4_address':
{ 'min': 0,
'one_time_price_per_unit': 0,
'recurring_price_per_unit': 9
},
{ 'min': 0,},
}
}
)
@ -433,7 +389,7 @@ class Product(models.Model):
@property
def recurring_orders(self):
return self.orders.order_by('id').exclude(recurring_period=RecurringPeriod.objects.get(name="ONE_TIME"))
return self.orders.order_by('id').exclude(recurring_price=0)
@property
def last_recurring_order(self):
@ -441,56 +397,12 @@ class Product(models.Model):
@property
def one_time_orders(self):
return self.orders.order_by('id').filter(recurring_period=RecurringPeriod.objects.get(name="ONE_TIME"))
return self.orders.order_by('id').filter(recurring_price=0)
@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.objects.get(name="ONE_TIME"),
description=str(self))
self.orders.add(one_time_order)
else:
one_time_order = None
if recurring_period != RecurringPeriod.objects.get(name="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:
@ -618,10 +530,83 @@ class Product(models.Model):
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
@ -650,6 +635,8 @@ class Order(models.Model):
billing_address = models.ForeignKey(BillingAddress,
on_delete=models.CASCADE)
customer = models.ForeignKey(StripeCustomer, on_delete=models.CASCADE, null=True)
description = models.TextField()
product = models.ForeignKey(Product, blank=False, null=False, on_delete=models.CASCADE)
@ -686,6 +673,7 @@ class Order(models.Model):
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)
@ -750,6 +738,17 @@ class Order(models.Model):
"""
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 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):
"""
@ -790,7 +789,7 @@ class Order(models.Model):
@property
def is_recurring(self):
return not self.recurring_period == RecurringPeriod.objects.get(name="ONE_TIME")
return self.recurring_price > 0
@property
def is_one_time(self):
@ -814,14 +813,12 @@ class Order(models.Model):
description=self.description,
product=self.product,
config=config,
pricing_plan=self.pricing_plan,
starting_date=starting_date,
currency=self.currency
)
(new_order.one_time_price, new_order.recurring_price, new_order.config) = new_order.calculate_prices_and_config()
new_order.recurring_price = new_order.calculate_recurring_price()
new_order.replaces = self
new_order.save()
@ -830,26 +827,28 @@ class Order(models.Model):
return new_order
def create_bill_record(self, bill):
br = None
# Note: check for != 0 not > 0, as we allow discounts to be expressed with < 0
if self.one_time_price != 0 and self.billrecord_set.count() == 0:
br = BillRecord.objects.create(bill=bill,
order=self,
starting_date=self.starting_date,
ending_date=self.starting_date,
is_recurring_record=False)
if self.recurring_price != 0:
br = BillRecord.objects.filter(bill=bill, order=self, is_recurring_record=True).first()
if br:
self.update_bill_record_for_recurring_order(br, bill)
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:
br = self.create_new_bill_record_for_recurring_order(bill)
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,
@ -861,22 +860,21 @@ class Order(models.Model):
# If the order has an ending date set, we might need to adjust the bill_record
if self.ending_date:
if bill_record_for_this_bill.ending_date != self.ending_date:
bill_record_for_this_bill.ending_date = 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_for_this_bill.ending_date < bill.ending_date:
bill_record_for_this_bill.ending_date = bill.ending_date
if bill_record.ending_date < bill.ending_date:
bill_record.ending_date = bill.ending_date
bill_record_for_this_bill.save()
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, is_recurring_record=True).order_by('id').last()
last_bill_record = BillRecord.objects.filter(order=self).order_by('id').last()
starting_date=self.starting_date
@ -892,7 +890,6 @@ class Order(models.Model):
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,
@ -901,47 +898,27 @@ class Order(models.Model):
ending_date=ending_date,
is_recurring_record=True)
def calculate_prices_and_config(self):
one_time_price = 0
recurring_price = 0
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'])
if self.config:
config = self.config
if 'features' not in self.config:
self.config['features'] = {}
else:
config = {
'features': {}
}
# FIXME: adjust prices to the selected recurring_period to the
if 'features' in self.product.config:
for feature in self.product.config['features']:
# Set min to 0 if not specified
min_val = self.product.config['features'][feature].get('min', 0)
# We might not even have 'features' cannot use .get() on it
try:
value = self.config['features'][feature]
except (KeyError, TypeError):
value = self.product.config['features'][feature]['min']
# Set max to current value if not specified
max_val = self.product.config['features'][feature].get('max', value)
if value < min_val or value > max_val:
raise ValidationError(f"Feature '{feature}' must be at least {min_val} and at maximum {max_val}. Value is: {value}")
one_time_price += self.product.config['features'][feature]['one_time_price_per_unit'] * value
recurring_price += self.product.config['features'][feature]['recurring_price_per_unit'] * value
config['features'][feature] = value
return (one_time_price, recurring_price, config)
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, 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:
@ -955,7 +932,7 @@ class Order(models.Model):
# IMMUTABLE fields -- need to create new order to modify them
# However this is not enforced here...
if self._state.adding:
(self.one_time_price, self.recurring_price, self.config) = self.calculate_prices_and_config()
self.recurring_price = self.calculate_recurring_price()
if self.recurring_period_id is None:
self.recurring_period = self.product.default_recurring_period
@ -975,12 +952,7 @@ class Order(models.Model):
def __str__(self):
try:
conf = " ".join([ f"{key}:{val}" for key,val in self.config['features'].items() if val != 0 ])
except KeyError:
conf = ""
return f"Order {self.id}: {self.description} {conf}"
return f"Order {self.id}: {self.description}"
class Bill(models.Model):
"""
@ -1003,7 +975,7 @@ class Bill(models.Model):
# FIXME: editable=True -> is in the admin, but also editable in DRF
# Maybe filter fields in the serializer?
is_final = models.BooleanField(default=False)
is_closed = models.BooleanField(default=False)
class Meta:
constraints = [
@ -1017,8 +989,9 @@ class Bill(models.Model):
"""
Close/finish a bill
"""
self.is_final = True
self.is_closed = True
if not self.ending_date:
self.ending_date = timezone.now()
self.save()
@property
@ -1028,34 +1001,7 @@ class Bill(models.Model):
@property
def vat_rate(self):
"""
Handling VAT is a tricky business - thus we only implement the cases
that we clearly now and leave it open to fellow developers to implement
correct handling for other cases.
Case CH:
- If the customer is in .ch -> apply standard rate
- If the customer is in EU AND private -> apply country specific rate
- If the customer is in EU AND business -> do not apply VAT
- If the customer is outside EU and outside CH -> do not apply VAT
"""
provider = UncloudProvider.objects.get()
# Assume always VAT inside the country
if provider.country == self.billing_address.country:
vat_rate = VATRate.objects.get(country=provider.country,
when=self.ending_date)
elif self.billing_address.country in EU:
# FIXME: need to check for validated vat number
if self.billing_address.vat_number:
return 0
else:
return VATRate.objects.get(country=self.biling_address.country,
when=self.ending_date)
else: # non-EU, non-national
return 0
return VATRate.get_vat_rate(self.billing_address, when=self.ending_date)
@classmethod
@ -1075,9 +1021,10 @@ class Bill(models.Model):
"""
bills = []
for billing_address in BillingAddress.objects.filter(owner=owner):
bills.append(cls.create_next_bill_for_user_address(billing_address, ending_date))
bill = cls.create_next_bill_for_user_address(billing_address, ending_date)
if bill:
bills.append(bill)
return bills
@ -1089,15 +1036,18 @@ class Bill(models.Model):
owner = billing_address.owner
all_orders = Order.objects.filter(owner=owner,
billing_address=billing_address).order_by('id')
bill = cls.get_or_create_bill(billing_address, ending_date=ending_date)
for order in all_orders:
order.create_bill_record(bill)
return bill
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
@ -1117,7 +1067,7 @@ class Bill(models.Model):
# Get date & bill from previous bill, if it exists
if last_bill:
if not last_bill.is_final:
if not last_bill.is_closed:
bill = last_bill
starting_date = last_bill.starting_date
ending_date = bill.ending_date
@ -1142,7 +1092,7 @@ class Bill(models.Model):
return bill
def __str__(self):
return f"{self.owner}-{self.id}"
@ -1167,9 +1117,11 @@ class BillRecord(models.Model):
if not self.is_recurring_record:
return 1
record_delta = self.ending_date - self.starting_date
return record_delta.total_seconds()/self.order.recurring_period.duration_seconds
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):