This commit is contained in:
Nico Schottelius 2020-10-07 00:54:56 +02:00
parent 2e74661702
commit 50fd9e1f37
2 changed files with 103 additions and 230 deletions

View file

@ -20,8 +20,8 @@ class Command(BaseCommand):
if line_count == 0: if line_count == 0:
line_count += 1 line_count += 1
obj, created = VATRate.objects.get_or_create( obj, created = VATRate.objects.get_or_create(
start_date=row["start_date"], starting_date=row["start_date"],
stop_date=row["stop_date"] if row["stop_date"] is not "" else None, ending_date=row["stop_date"] if row["stop_date"] is not "" else None,
territory_codes=row["territory_codes"], territory_codes=row["territory_codes"],
currency_code=row["currency_code"], currency_code=row["currency_code"],
rate=row["rate"], rate=row["rate"],

View file

@ -18,7 +18,6 @@ from calendar import monthrange
from decimal import Decimal from decimal import Decimal
import uncloud_pay.stripe 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_pay import AMOUNT_DECIMALS, AMOUNT_MAX_DIGITS, COUNTRIES
from uncloud.models import UncloudModel, UncloudStatus from uncloud.models import UncloudModel, UncloudStatus
@ -64,12 +63,9 @@ def start_after(a_date):
def default_payment_delay(): def default_payment_delay():
return timezone.now() + BILL_PAYMENT_DELAY return timezone.now() + BILL_PAYMENT_DELAY
class Currency(models.TextChoices): class Currency(models.TextChoices):
""" """
We don't support months are years, because they vary in length. Possible currencies to be billed
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') CHF = 'CHF', _('Swiss Franc')
EUR = 'EUR', _('Euro') EUR = 'EUR', _('Euro')
@ -97,12 +93,32 @@ def get_balance_for_user(user):
0) 0)
return payments - bills return payments - bills
###
# Stripe
class StripeCustomer(models.Model): class StripeCustomer(models.Model):
owner = models.OneToOneField( get_user_model(), owner = models.OneToOneField( get_user_model(),
primary_key=True, primary_key=True,
on_delete=models.CASCADE) on_delete=models.CASCADE)
stripe_id = models.CharField(max_length=32) stripe_id = models.CharField(max_length=32)
###
# Hosting company configuration
class HostingProvider(models.Model):
"""
A class resembling who is running this uncloud instance.
This might change over time so we allow starting/ending dates
This also defines the taxation rules
WIP.
"""
starting_date = models.DateField()
ending_date = models.DateField()
### ###
# Payments and Payment Methods. # Payments and Payment Methods.
@ -393,6 +409,55 @@ class Product(UncloudModel):
'one_time_price_per_unit': 0, 'one_time_price_per_unit': 0,
'recurring_price_per_unit': 15/1000 'recurring_price_per_unit': 15/1000
}, },
'additional_ipv4_address':
{ 'min': 0,
'one_time_price_per_unit': 0,
'recurring_price_per_unit': 8
},
}
}
)
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,
'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
},
'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': 9
},
} }
} }
) )
@ -544,6 +609,8 @@ class Product(UncloudModel):
""" """
# 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 self.default_recurring_period == RecurringPeriod.PER_365D:
if requested_period == RecurringPeriod.PER_365D: if requested_period == RecurringPeriod.PER_365D:
@ -669,21 +736,26 @@ class Order(models.Model):
return self.starting_date + datetime.timedelta(seconds=self.recurring_period.duration_seconds) return self.starting_date + datetime.timedelta(seconds=self.recurring_period.duration_seconds)
@property
def next_cancel_or_downgrade_date(self): def next_cancel_or_downgrade_date(self, until_when=None):
""" """
Return the next proper ending date after n times the Return the next proper ending date after n times the
recurring_period, where n is an integer that applies for downgrading recurring_period, where n is an integer that applies for downgrading
or cancelling. or cancelling.
""" """
if self.recurring_period.seconds > 0: if not until_when:
now = timezone.now() until_when = timezone.now()
delta = now - self.starting_date
num_times = ceil(delta.total_seconds() / self.recurring_period) if until_when < self.starting_date:
raise ValidationError("Cannot end before start of start of order")
next_date = self.starting_date + datetime.timedelta(seconds= num_times * self.recurring_period) 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: else:
next_date = self.starting_date next_date = self.starting_date
@ -694,12 +766,13 @@ class Order(models.Model):
Determine the ending date given a specific bill Determine the ending date given a specific bill
""" """
# If the order is quit, charge the final amount (?) # If the order is quit, charge the final amount / finish (????)
# Probably not a good idea -- FIXME :continue until usual
if self.ending_date: if self.ending_date:
this_ending_date = self.ending_date this_ending_date = self.ending_date
else: else:
if self.earliest_ending_date > bill.ending_date: if self.next_cancel_or_downgrade_date(bill.ending_date) > bill.ending_date:
this_ending_date = self.earliest_ending_date this_ending_date = self.next_cancel_or_downgrade_date(bill.ending_date)
else: else:
this_ending_date = bill.ending_date this_ending_date = bill.ending_date
@ -796,6 +869,7 @@ class Order(models.Model):
def create_bill_record(self, bill): def create_bill_record(self, bill):
br = None 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: if self.one_time_price != 0 and self.billrecord_set.count() == 0:
br = BillRecord.objects.create(bill=bill, br = BillRecord.objects.create(bill=bill,
order=self, order=self,
@ -808,7 +882,6 @@ class Order(models.Model):
if br: if br:
self.update_bill_record_for_recurring_order(br, bill) self.update_bill_record_for_recurring_order(br, bill)
else: else:
br = self.create_new_bill_record_for_recurring_order(bill) br = self.create_new_bill_record_for_recurring_order(bill)
@ -948,7 +1021,9 @@ class Bill(models.Model):
on_delete=models.CASCADE, on_delete=models.CASCADE,
editable=True, editable=True,
null=False) null=False)
# FIXME: editable=True -> is in the admin, but also editable in DRF # 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_final = models.BooleanField(default=False)
@ -960,9 +1035,6 @@ class Bill(models.Model):
name='one_bill_per_month_per_user') name='one_bill_per_month_per_user')
] ]
def __str__(self):
return f"Bill {self.owner}-{self.id}"
def close(self): def close(self):
""" """
Close/finish a bill Close/finish a bill
@ -1001,7 +1073,6 @@ class Bill(models.Model):
@classmethod @classmethod
def create_next_bill_for_user_address(cls, billing_address, ending_date=None): 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 Create the next bill for a specific billing address of a user
""" """
@ -1021,6 +1092,12 @@ class Bill(models.Model):
@classmethod @classmethod
def get_or_create_bill(cls, billing_address, ending_date=None): 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() 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') all_orders = Order.objects.filter(billing_address=billing_address).order_by('id')
@ -1056,163 +1133,8 @@ class Bill(models.Model):
return bill return bill
# @classmethod def __str__(self):
# def create_bill_records_for_recurring_orders(cls, bill): return f"Bill {self.owner}-{self.id}"
# """
# Create or update bill records for recurring orders for the
# given bill
# """
# owner = bill.owner
# billing_address = bill.billing_address
# all_orders = Order.objects.filter(owner=owner,
# billing_address=billing_address).order_by('id').exclude(recurring_period=RecurringPeriod.ONE_TIME)
# for order in all_orders:
# bill_record_for_this_bill = BillRecord.objects.filter(bill=bill,
# order=order).first()
# if bill_record_for_this_bill:
# cls.update_bill_record_for_this_bill(bill_record_for_this_bill,
# order,
# bill)
# else:
# cls.create_new_bill_record_for_this_bill(order, bill)
@classmethod
def create_next_bill_for_user_address_old(cls,
owner,
billing_address,
ending_date=None):
"""
Filtering ideas (TBD):
If order is replaced, it should not be added anymore if it has been billed "last time"
If order has ended and finally charged, do not charge anymore
Find out the last billrecord for the order, if there is none, bill from the starting date
"""
last_bill = cls.objects.filter(owner=owner, billing_address=billing_address).order_by('id').last()
# it is important to sort orders here, as bill records will be
# created (and listed) in this order
all_orders = Order.objects.filter(owner=owner,
billing_address=billing_address).order_by('id')
first_order = all_orders.first()
bill = None
ending_date = None
# Get date & bill from previous bill, if it exists
if last_bill:
if not last_bill.is_final:
bill = last_bill
starting_date = last_bill.starting_date
# FIXME: take given (parameter) or existing ending_date?
ending_date = bill.ending_date
else:
starting_date = last_bill.ending_date + datetime.timedelta(seconds=1)
else:
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=owner,
starting_date=starting_date,
ending_date=ending_date,
billing_address=billing_address)
for order in all_orders:
if order.is_one_time:
# this code should be ok, but needs to be double checked
if order.billrecord_set.count() == 0:
br = BillRecord.objects.create(bill=bill,
order=order,
starting_date=order.starting_date,
ending_date=order.ending_date)
else: # recurring orders
bill_record_for_this_bill = BillRecord.objects.filter(bill=bill,
order=order).first()
# This bill already has a bill record for the order
# We potentially need to update the ending_date if the ending_date
# of the bill changed.
if bill_record_for_this_bill:
# we may need to adjust it, but let's do this logic another time
# If the order has an ending date set, we might need to adjust the bill_record
if order.ending_date:
if bill_record_for_this_bill.ending_date != order.ending_date:
bill_record_for_this_bill.ending_date = order.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
bill_record_for_this_bill.save()
else: # No bill record in this bill for this order yet
# Find out when billed last time
last_bill_record = BillRecord.objects.filter(order=order).order_by('id').last()
# Default starting date
this_starting_date=order.starting_date
# Skip billing again, if we have been processed for this bill duration
if last_bill_record:
if last_bill_record.ending_date >= bill.ending_date:
continue
# If the order ended and has been fully billed - do not process it
# anymore
if order.ending_date:
if last_bill_record.ending_date == order.ending_date:
# FIXME: maybe mark order for not processing anymore?
# I imagina a boolean column, once this code is stable and
# verified
continue
# Catch programming bugs if the last bill_record was
# created incorrectly - should never be entered!
if order.ending_date < last_bill_record.ending_date:
raise ValidationError(f"Order {order.id} ends before last bill record {last_bill_record.id}")
# Start right after last billing run
this_starting_date = last_bill_record.ending_date + datetime.timedelta(seconds=1)
# If the order is already terminated, use that date instead of bill date
if order.ending_date:
this_ending_date = order.ending_date
else:
if order.earliest_ending_date > bill.ending_date:
this_ending_date = order.earliest_ending_date
else:
# bill at maximum for this billing period
this_ending_date = bill.ending_date
# And finally create a new billrecord!
br = BillRecord.objects.create(bill=bill,
order=order,
starting_date=this_starting_date,
ending_date=this_ending_date)
return bill
class BillRecord(models.Model): class BillRecord(models.Model):
@ -1261,10 +1183,10 @@ class BillRecord(models.Model):
super().save(*args, **kwargs) super().save(*args, **kwargs)
class ProductToRecurringPeriod(models.Model): class ProductToRecurringPeriod(models.Model):
""" """
Intermediate manytomany mapping class Intermediate manytomany mapping class that allows storing the default recurring period
for a product
""" """
recurring_period = models.ForeignKey(RecurringPeriod, on_delete=models.CASCADE) recurring_period = models.ForeignKey(RecurringPeriod, on_delete=models.CASCADE)
@ -1283,52 +1205,3 @@ class ProductToRecurringPeriod(models.Model):
def __str__(self): def __str__(self):
return f"{self.product} - {self.recurring_period} (default: {self.is_default})" return f"{self.product} - {self.recurring_period} (default: {self.is_default})"
# # Sample products included into uncloud
# class SampleOneTimeProduct(models.Model):
# """
# Products are usually more complex, but this product shows how easy
# it can be to create your own one time product.
# """
# default_recurring_period = RecurringPeriod.ONE_TIME
# ot_price = models.IntegerField(default=5)
# @property
# def one_time_price(self):
# return self.ot_price
# class SampleRecurringProduct(models.Model):
# """
# Products are usually more complex, but this product shows how easy
# it can be to create your own recurring fee product.
# """
# default_recurring_period = RecurringPeriod.PER_30D
# rc_price = models.IntegerField(default=10)
# @property
# def recurring_price(self):
# return self.rc_price
# class SampleRecurringProductOneTimeFee(models.Model):
# """
# Products are usually more complex, but this product shows how easy
# it can be to create your own one time + recurring fee product.
# """
# default_recurring_period = RecurringPeriod.PER_30D
# ot_price = models.IntegerField(default=5)
# rc_price = models.IntegerField(default=10)
# @property
# def one_time_price(self):
# return self.ot_price
# @property
# def recurring_price(self):
# return self.rc_price