uncloud/uncloud_pay/models.py

1323 lines
48 KiB
Python
Raw Normal View History

2020-02-27 10:21:38 +00:00
from django.db import models
from django.db.models import Q
2020-02-27 10:21:38 +00:00
from django.contrib.auth import get_user_model
from django.utils.translation import gettext_lazy as _
from django.core.validators import MinValueValidator
2020-02-29 08:08:30 +00:00
from django.utils import timezone
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
import logging
from functools import reduce
import itertools
from math import ceil
2020-06-21 14:08:00 +00:00
import datetime
from calendar import monthrange
from decimal import Decimal
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
2020-03-22 19:55:11 +00:00
from uncloud.models import UncloudModel, UncloudStatus
2020-02-27 10:21:38 +00:00
2020-03-03 10:15:48 +00:00
from decimal import Decimal
import decimal
# Used to generate bill due dates.
2020-06-21 14:08:00 +00:00
BILL_PAYMENT_DELAY=datetime.timedelta(days=10)
# Initialize logger.
logger = logging.getLogger(__name__)
2020-06-21 14:08:00 +00:00
def start_of_month(a_day):
""" Returns first of the month of a given datetime object"""
return a_day.replace(day=1,hour=0,minute=0,second=0, microsecond=0)
def end_of_month(a_day):
""" Returns first of the month of a given datetime object"""
_, last_day = monthrange(a_day.year, a_day.month)
return a_day.replace(day=last_day,hour=23,minute=59,second=59, microsecond=0)
def start_of_this_month():
""" Returns first of this month"""
a_day = timezone.now()
return a_day.replace(day=1,hour=0,minute=0,second=0, microsecond=0)
def end_of_this_month():
""" Returns first of this month"""
a_day = timezone.now()
_, last_day = monthrange(a_day.year, a_day.month)
return a_day.replace(day=last_day,hour=23,minute=59,second=59, microsecond=0)
def end_before(a_date):
""" Return suitable datetimefield for ending just before a_date """
return a_date - datetime.timedelta(seconds=1)
2020-08-27 20:00:54 +00:00
def start_after(a_date):
""" Return suitable datetimefield for starting just after a_date """
return a_date + datetime.timedelta(seconds=1)
2020-06-21 14:08:00 +00:00
def default_payment_delay():
return timezone.now() + BILL_PAYMENT_DELAY
2020-05-23 21:08:59 +00:00
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')
2020-04-15 13:17:38 +00:00
class CountryField(models.CharField):
def __init__(self, *args, **kwargs):
kwargs.setdefault('choices', COUNTRIES)
kwargs.setdefault('default', 'CH')
kwargs.setdefault('max_length', 2)
2020-05-24 11:45:03 +00:00
super().__init__(*args, **kwargs)
2020-04-15 13:17:38 +00:00
def get_internal_type(self):
return "CharField"
2020-03-18 13:36:40 +00:00
def get_balance_for_user(user):
bills = reduce(
lambda acc, entry: acc + entry.total,
Bill.objects.filter(owner=user),
0)
payments = reduce(
lambda acc, entry: acc + entry.amount,
Payment.objects.filter(owner=user),
0)
return payments - bills
class StripeCustomer(models.Model):
owner = models.OneToOneField( get_user_model(),
primary_key=True,
on_delete=models.CASCADE)
stripe_id = models.CharField(max_length=32)
###
# Payments and Payment Methods.
class Payment(models.Model):
owner = models.ForeignKey(get_user_model(),
on_delete=models.CASCADE)
amount = models.DecimalField(
default=0.0,
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'),
('unknown', 'Unknown')
),
default='unknown')
timestamp = models.DateTimeField(editable=False, auto_now_add=True)
# We override save() in order to active products awaiting payment.
def save(self, *args, **kwargs):
# _state.adding is switched to false after super(...) call.
being_created = self._state.adding
unpaid_bills_before_payment = Bill.get_unpaid_for(self.owner)
super(Payment, self).save(*args, **kwargs) # Save payment in DB.
unpaid_bills_after_payment = Bill.get_unpaid_for(self.owner)
newly_paid_bills = list(
set(unpaid_bills_before_payment) - set(unpaid_bills_after_payment))
for bill in newly_paid_bills:
bill.activate_products()
2020-05-23 21:08:59 +00:00
class PaymentMethod(models.Model):
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 stripe_card_last4(self):
if self.source == 'stripe' and self.active:
payment_method = uncloud_pay.stripe.get_payment_method(
self.stripe_payment_method_id)
return payment_method.card.last4
else:
return None
@property
def active(self):
if self.source == 'stripe' and self.stripe_payment_method_id != None:
return True
else:
return False
def charge(self, amount):
2020-03-05 10:03:47 +00:00
if not self.active:
raise Exception('This payment method is inactive.')
if amount < 0: # Make sure we don't charge negative amount by errors...
2020-03-05 10:03:47 +00:00
raise Exception('Cannot charge negative amount.')
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)
2020-03-05 10:03:47 +00:00
return payment
else:
2020-03-05 10:03:47 +00:00
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?
2020-03-05 10:03:47 +00:00
if method.active and method.primary:
return method
return None
class Meta:
2020-03-05 10:03:47 +00:00
# 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
# See https://docs.djangoproject.com/en/dev/ref/models/fields/#field-choices-enum-types
class RecurringPeriodChoices(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 RecurringPeriodChoices.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})"
###
2020-03-05 09:27:33 +00:00
# Bills.
class BillingAddress(models.Model):
owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE)
2020-08-01 22:55:07 +00:00
organization = models.CharField(max_length=100, blank=True, null=True)
name = models.CharField(max_length=100)
street = models.CharField(max_length=100)
city = models.CharField(max_length=50)
postal_code = models.CharField(max_length=50)
country = CountryField(blank=True)
vat_number = models.CharField(max_length=100, default="", blank=True)
active = models.BooleanField(default=False)
class Meta:
constraints = [
models.UniqueConstraint(fields=['owner'],
2020-06-21 12:45:05 +00:00
condition=Q(active=True),
name='one_active_billing_address_per_user')
]
@staticmethod
2020-08-01 22:55:07 +00:00
def get_address_for(user):
return BillingAddress.objects.get(owner=user, active=True)
def __str__(self):
2020-06-21 11:46:54 +00:00
return "{} - {}, {}, {} {}, {}".format(
self.owner,
self.name, self.street, self.postal_code, self.city,
self.country)
###
# 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
###
# 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, 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': {
'base':
{ 'min': 1,
'max': 1,
'one_time_price_per_unit': 0,
'recurring_price_per_unit': 8
},
'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': 1,
'one_time_price_per_unit': 0,
'recurring_price_per_unit': 3.5
},
'hdd_gb':
{ 'min': 0,
'one_time_price_per_unit': 0,
'recurring_price_per_unit': 15/1000
},
}
}
)
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_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 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)
"""
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.
2020-02-27 10:21:38 +00:00
class Order(models.Model):
2020-05-24 11:45:03 +00:00
"""
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
2020-05-24 11:45:03 +00:00
"""
2020-02-27 10:21:38 +00:00
owner = models.ForeignKey(get_user_model(),
on_delete=models.CASCADE,
editable=True)
billing_address = models.ForeignKey(BillingAddress,
on_delete=models.CASCADE)
2020-05-07 10:08:18 +00:00
description = models.TextField()
2020-02-27 10:21:38 +00:00
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)
2020-09-28 19:59:35 +00:00
one_time_price = models.DecimalField(default=0.0,
max_digits=AMOUNT_MAX_DIGITS,
decimal_places=AMOUNT_DECIMALS,
validators=[MinValueValidator(0)])
2020-09-28 19:59:35 +00:00
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)
2020-05-08 14:47:32 +00:00
depends_on = models.ForeignKey('self',
related_name='parent_of',
on_delete=models.CASCADE,
blank=True,
null=True)
2020-05-08 14:47:32 +00:00
2020-09-28 21:16:17 +00:00
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
"""
2020-08-09 08:18:15 +00:00
return self.starting_date + datetime.timedelta(seconds=self.recurring_period)
2020-08-27 20:00:54 +00:00
@property
def next_cancel_or_downgrade_date(self):
2020-08-27 20:00:54 +00:00
"""
Return the next proper ending date after n times the
recurring_period, where n is an integer that applies for downgrading
or cancelling.
2020-08-27 20:00:54 +00:00
"""
if self.recurring_period > 0:
now = timezone.now()
delta = now - self.starting_date
num_times = ceil(delta.total_seconds() / self.recurring_period)
2020-08-27 20:00:54 +00:00
next_date = self.starting_date + datetime.timedelta(seconds= num_times * self.recurring_period)
else:
next_date = self.starting_date
return next_date
def get_ending_date_for_bill(self, bill):
2020-09-03 15:16:18 +00:00
"""
Determine the ending date given a specific bill
"""
# If the order is quit, charge the final amount (?)
if self.ending_date:
this_ending_date = self.ending_date
2020-09-03 15:16:18 +00:00
else:
if self.earliest_ending_date > bill.ending_date:
this_ending_date = self.earliest_ending_date
2020-09-03 15:16:18 +00:00
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
"""
2020-06-21 14:42:55 +00:00
return sum([ br.quantity for br in self.bill_records.all() ])
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
2020-09-03 15:16:18 +00:00
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"?
"""
2020-09-03 15:16:18 +00:00
if self.count_billed == self.count_used(when):
return True
else:
return False
@property
def is_closed(self):
if self.all_usage_billed and self.ending_date:
return True
else:
return False
2020-05-23 19:32:56 +00:00
@property
def is_recurring(self):
return not self.recurring_period == RecurringPeriod.ONE_TIME
@property
def is_one_time(self):
return not self.is_recurring
2020-08-09 10:34:25 +00:00
def replace_with(self, new_order):
new_order.replaces = self
self.ending_date = end_before(new_order.starting_date)
2020-08-09 10:34:25 +00:00
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,
starting_date=starting_date,
config=config)
(new_order_one_time_price, new_order_recurring_price) = new_order.prices
new_order.replaces = self
new_order.save()
self.ending_date = end_before(new_order.starting_date)
self.save()
def save(self, *args, **kwargs):
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 create_bill_record(self, bill):
br = None
2020-09-28 21:16:17 +00:00
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)
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_for_this_bill.ending_date != self.ending_date:
bill_record_for_this_bill.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
bill_record_for_this_bill.save()
def create_new_bill_record_for_recurring_order(self, bill):
"""
Create a new bill record
"""
2020-09-28 21:16:17 +00:00
last_bill_record = BillRecord.objects.filter(order=self, is_recurring_record=True).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,
2020-09-28 21:16:17 +00:00
order=self,
starting_date=starting_date,
ending_date=ending_date,
is_recurring_record=True)
@property
def prices(self):
2020-09-28 19:59:35 +00:00
one_time_price = 0
recurring_price = 0
# FIXME: adjust to the selected recurring_period
2020-09-28 19:59:35 +00:00
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:
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
2020-09-28 19:59:35 +00:00
return (one_time_price, recurring_price)
2020-09-28 19:59:35 +00:00
def save(self, *args, **kwargs):
# Calculate the price of the order when we create it
2020-09-28 19:59:35 +00:00
# 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.prices
2020-09-28 19:59:35 +00:00
if self.recurring_period_id is None:
self.recurring_period = self.product.default_recurring_period
# FIXME: ensure the recurring period is defined in the product
2020-09-28 19:59:35 +00:00
super().save(*args, **kwargs)
def __str__(self):
return f"Order {self.id} from {self.owner}: {self.product}"
2020-06-21 11:46:54 +00:00
2020-06-21 14:08:00 +00:00
class Bill(models.Model):
"""
A bill is a representation of usage at a specific time
"""
2020-06-21 11:46:54 +00:00
owner = models.ForeignKey(get_user_model(),
on_delete=models.CASCADE)
creation_date = models.DateTimeField(auto_now_add=True)
2020-06-21 14:08:00 +00:00
starting_date = models.DateTimeField(default=start_of_this_month)
ending_date = models.DateTimeField()
2020-06-21 14:08:00 +00:00
due_date = models.DateField(default=default_payment_delay)
2020-08-09 10:34:25 +00:00
billing_address = models.ForeignKey(BillingAddress,
on_delete=models.CASCADE,
editable=True,
null=False)
# FIXME: editable=True -> is in the admin, but also editable in DRF
is_final = models.BooleanField(default=False)
2020-06-21 11:46:54 +00:00
2020-06-21 14:08:00 +00:00
class Meta:
constraints = [
models.UniqueConstraint(fields=['owner',
'starting_date',
'ending_date' ],
name='one_bill_per_month_per_user')
]
2020-06-21 11:46:54 +00:00
def __str__(self):
2020-06-21 14:08:00 +00:00
return f"Bill {self.owner}-{self.id}"
2020-06-21 11:46:54 +00:00
2020-08-09 10:34:25 +00:00
def close(self):
"""
Close/finish a bill
"""
self.is_final = True
self.save()
@property
def sum(self):
bill_records = BillRecord.objects.filter(bill=self)
return sum([ br.sum for br in bill_records ])
@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
2020-08-09 10:34:25 +00:00
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):
bills.append(cls.create_next_bill_for_user_address(billing_address, ending_date))
2020-08-09 10:34:25 +00:00
return bills
@classmethod
def get_or_create_bill(cls, billing_address):
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
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
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
@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(owner=owner,
billing_address=billing_address).order_by('id')
bill = cls.get_or_create_bill(billing_address)
for order in all_orders:
order.create_bill_record(bill)
return bill
# @classmethod
# def create_bill_records_for_recurring_orders(cls, bill):
# """
# 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
"""
2020-08-09 10:34:25 +00:00
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
ending_date = bill.ending_date
else:
2020-08-09 08:18:15 +00:00
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,
2020-08-09 10:34:25 +00:00
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)
2020-08-08 21:02:24 +00:00
return bill
2020-06-21 11:46:54 +00:00
class BillRecord(models.Model):
"""
Entry of a bill, dynamically generated from an order.
"""
bill = models.ForeignKey(Bill, on_delete=models.CASCADE)
order = models.ForeignKey(Order, on_delete=models.CASCADE)
creation_date = models.DateTimeField(auto_now_add=True)
starting_date = models.DateTimeField()
ending_date = models.DateTimeField()
2020-09-28 21:16:17 +00:00
is_recurring_record = models.BooleanField(blank=False, null=False)
@property
def quantity(self):
""" Determine the quantity by the duration"""
2020-09-28 21:16:17 +00:00
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
@property
def sum(self):
2020-09-28 21:16:17 +00:00
if self.is_recurring_record:
return self.order.recurring_price * Decimal(self.quantity)
else:
return self.order.one_time_price
2020-06-21 11:46:54 +00:00
def __str__(self):
2020-09-28 21:16:17 +00:00
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
2020-06-21 11:46:54 +00:00
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
"""
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=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})"
# # 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