uncloud-mravi/uncloud_pay/models.py

991 lines
37 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
import uuid
import logging
from functools import reduce
import itertools
from math import ceil
from datetime import timedelta
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.
BILL_PAYMENT_DELAY=timedelta(days=10)
# Initialize logger.
logger = logging.getLogger(__name__)
# See https://docs.djangoproject.com/en/dev/ref/models/fields/#field-choices-enum-types
class RecurringPeriod(models.IntegerChoices):
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')
2020-05-23 21:08:59 +00:00
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)
super(CountryField, self).__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):
uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
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):
uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
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
###
2020-03-05 09:27:33 +00:00
# Bills.
class BillingAddress(models.Model):
uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE)
2020-05-07 13:38:49 +00:00
organization = models.CharField(max_length=100)
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)
@staticmethod
def get_addresses_for(user):
return BillingAddress.objects.filter(owner=user)
@classmethod
def get_preferred_address_for(cls, user):
addresses = cls.get_addresses_for(user)
if len(addresses) == 0:
return None
else:
# TODO: allow user to set primary/preferred address
return addresses[0]
def __str__(self):
return "{}, {}, {} {}, {}".format(
self.name, self.street, self.postal_code, self.city,
self.country)
# Populated with the import-vat-numbers django command.
class VATRate(models.Model):
start_date = models.DateField(blank=True, null=True)
stop_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
2020-05-07 18:22:42 +00:00
class BillNico(models.Model):
2020-05-23 19:23:06 +00:00
""" FIXME:
Bill needs to be unique in the triple (owner, year, month)
"""
2020-05-07 18:22:42 +00:00
uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
owner = models.ForeignKey(get_user_model(),
on_delete=models.CASCADE)
creation_date = models.DateTimeField(auto_now_add=True)
starting_date = models.DateTimeField()
ending_date = models.DateTimeField()
due_date = models.DateField()
valid = models.BooleanField(default=True)
2020-05-23 19:23:06 +00:00
@staticmethod
def create_all_bills():
for owner in get_user_model().objects.all():
# mintime = time of first order
# maxtime = time of last order
# iterate month based through it
pass
def assign_orders_to_bill(self, owner, year, month):
"""
Generate a bill for the specific month of a user.
First handle all one time orders
2020-05-23 21:08:59 +00:00
FIXME:
- limit this to active users in the future! (2020-05-23)
2020-05-23 19:23:06 +00:00
"""
"""
Find all one time orders that have a starting date that falls into this month
recurring_period=RecurringPeriod.ONE_TIME,
Can we do this even for recurring / all of them
"""
# FIXME: add something to check whether the order should be billed at all - i.e. a marker that
# disables searching -> optimization for later
2020-05-23 21:08:59 +00:00
for order in Order.objects.filter(Q(starting_date__gte=self.starting_date),
Q(starting_date__lte=self.ending_date),
owner=owner):
# order.bill.add(this_bill)
pass
2020-05-23 19:23:06 +00:00
"""
Find all recurring orders that did not start in this time frame, but need
to be billed in this time frame.
This is:
- order starting time before our starting time
- order start time + (x * (the_period)) is inside our time frame, x must be integer
test cases:
+ 365days:
time_since_last_billed = self.starting_or_ending_date - order.last_bill_date
periods =
[ we could in theory add this as a property to the order: next
"""
2020-05-23 21:08:59 +00:00
for order in Order.objects.filter(~Q(recurring_period=RecurringPeriod.ONE_TIME),
Q(starting_date__lt=self.starting_date),
owner=owner):
if order.recurring_period > 0: # avoid div/0 - these are one time payments
pass
2020-05-23 19:23:06 +00:00
2020-02-27 10:21:38 +00:00
class Bill(models.Model):
uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
2020-02-27 10:21:38 +00:00
owner = models.ForeignKey(get_user_model(),
2020-02-27 14:50:46 +00:00
on_delete=models.CASCADE)
2020-02-27 10:21:38 +00:00
creation_date = models.DateTimeField(auto_now_add=True)
2020-02-27 10:21:38 +00:00
starting_date = models.DateTimeField()
ending_date = models.DateTimeField()
due_date = models.DateField()
valid = models.BooleanField(default=True)
# Trigger product activation if bill paid at creation (from balance).
def save(self, *args, **kwargs):
super(Bill, self).save(*args, **kwargs)
if not self in Bill.get_unpaid_for(self.owner):
self.activate_products()
2020-03-06 08:39:41 +00:00
@property
def reference(self):
2020-03-06 10:11:16 +00:00
return "{}-{}".format(
2020-03-06 08:39:41 +00:00
self.owner.username,
self.creation_date.strftime("%Y-%m-%d-%H%M"))
2020-02-27 10:21:38 +00:00
@property
def records(self):
bill_records = []
orders = Order.objects.filter(bill=self)
for order in orders:
2020-05-07 12:24:04 +00:00
bill_record = BillRecord(self, order)
bill_records.append(bill_record)
return bill_records
2020-02-27 10:21:38 +00:00
2020-02-29 08:08:30 +00:00
@property
def amount(self):
2020-03-03 10:15:48 +00:00
return reduce(lambda acc, record: acc + record.amount, self.records, 0)
2020-02-29 08:08:30 +00:00
@property
def vat_amount(self):
return reduce(lambda acc, record: acc + record.vat_amount, self.records, 0)
@property
def total(self):
return self.amount + self.vat_amount
@property
def final(self):
2020-05-08 07:31:46 +00:00
# A bill is final when its ending date is passed, or when all of its
# orders have been terminated.
every_order_terminated = True
billing_period_is_over = self.ending_date < timezone.now()
for order in self.order_set.all():
every_order_terminated = every_order_terminated and order.is_terminated
return billing_period_is_over or every_order_terminated
def activate_products(self):
for order in self.order_set.all():
# FIXME: using __something might not be a good idea.
for product_class in Product.__subclasses__():
for product in product_class.objects.filter(order=order):
if product.status == UncloudStatus.AWAITING_PAYMENT:
product.status = UncloudStatus.PENDING
product.save()
2020-04-18 07:20:06 +00:00
@property
def billing_address(self):
orders = Order.objects.filter(bill=self)
# The genrate_for method makes sure all the orders of a bill share the
# same billing address. TODO: It would be nice to enforce that somehow...
if orders:
return orders[0].billing_address
else:
return None
# TODO: split this huuuge method!
@staticmethod
def generate_for(year, month, user):
# /!\ We exclusively work on the specified year and month.
2020-03-05 15:22:41 +00:00
generated_bills = []
2020-03-05 15:22:41 +00:00
# Default values for next bill (if any).
starting_date=beginning_of_month(year, month)
ending_date=end_of_month(year, month)
creation_date=timezone.now()
2020-03-05 15:22:41 +00:00
# Select all orders active on the request period (i.e. starting on or after starting_date).
orders = Order.objects.filter(
2020-03-05 15:22:41 +00:00
Q(ending_date__gte=starting_date) | Q(ending_date__isnull=True),
owner=user)
# Check if there is already a bill covering the order and period pair:
# * Get latest bill by ending_date: previous_bill.ending_date
2020-03-05 15:22:41 +00:00
# * For monthly bills: if previous_bill.ending_date is before
# (next_bill) ending_date, a new bill has to be generated.
# * For yearly bill: if previous_bill.ending_date is on working
# month, generate new bill.
unpaid_orders = { 'monthly_or_less': [], 'yearly': {} }
for order in orders:
try:
previous_bill = order.bill.latest('ending_date')
except ObjectDoesNotExist:
previous_bill = None
2020-03-05 15:22:41 +00:00
# FIXME: control flow is confusing in this block.
# if order.recurring_period == RecurringPeriod.PER_YEAR:
# # We ignore anything smaller than a day in here.
# next_yearly_bill_start_on = None
# if previous_bill == None:
# next_yearly_bill_start_on = order.starting_date
# elif previous_bill.ending_date <= ending_date:
# next_yearly_bill_start_on = (previous_bill.ending_date + timedelta(days=1))
# # Store for bill generation. One bucket per day of month with a starting bill.
# # bucket is a reference here, no need to reassign.
# if next_yearly_bill_start_on:
# # We want to group orders by date but keep using datetimes.
# next_yearly_bill_start_on = next_yearly_bill_start_on.replace(
# minute=0, hour=0, second=0, microsecond=0)
# bucket = unpaid_orders['yearly'].get(next_yearly_bill_start_on)
# if bucket == None:
# unpaid_orders['yearly'][next_yearly_bill_start_on] = [order]
# else:
# unpaid_orders['yearly'][next_yearly_bill_start_on] = bucket + [order]
# else:
# if previous_bill == None or previous_bill.ending_date < ending_date:
# unpaid_orders['monthly_or_less'].append(order)
2020-03-05 15:22:41 +00:00
# Handle working month's billing.
if len(unpaid_orders['monthly_or_less']) > 0:
# TODO: PREPAID billing is not supported yet.
prepaid_due_date = min(creation_date, starting_date) + BILL_PAYMENT_DELAY
postpaid_due_date = max(creation_date, ending_date) + BILL_PAYMENT_DELAY
# There should not be any bill linked to orders with different
# billing addresses.
per_address_orders = itertools.groupby(
unpaid_orders['monthly_or_less'],
lambda o: o.billing_address)
for addr, bill_orders in per_address_orders:
next_monthly_bill = Bill.objects.create(owner=user,
2020-03-05 15:22:41 +00:00
creation_date=creation_date,
starting_date=starting_date, # FIXME: this is a hack!
2020-03-05 15:22:41 +00:00
ending_date=ending_date,
due_date=postpaid_due_date)
# It is not possible to register many-to-many relationship before
# the two end-objects are saved in database.
for order in bill_orders:
order.bill.add(next_monthly_bill)
logger.info("Generated monthly bill {} (amount: {}) for user {}."
2020-03-05 15:22:41 +00:00
.format(next_monthly_bill.uuid, next_monthly_bill.total, user))
# Add to output.
generated_bills.append(next_monthly_bill)
2020-03-05 15:22:41 +00:00
# Handle yearly bills starting on working month.
if len(unpaid_orders['yearly']) > 0:
# For every starting date, generate new bill.
for next_yearly_bill_start_on in unpaid_orders['yearly']:
# No postpaid for yearly payments.
prepaid_due_date = min(creation_date, next_yearly_bill_start_on) + BILL_PAYMENT_DELAY
# Bump by one year, remove one day.
ending_date = next_yearly_bill_start_on.replace(
year=next_yearly_bill_start_on.year+1) - timedelta(days=1)
2020-03-05 15:22:41 +00:00
# There should not be any bill linked to orders with different
# billing addresses.
per_address_orders = itertools.groupby(
unpaid_orders['yearly'][next_yearly_bill_start_on],
lambda o: o.billing_address)
for addr, bill_orders in per_address_orders:
next_yearly_bill = Bill.objects.create(owner=user,
creation_date=creation_date,
starting_date=next_yearly_bill_start_on,
ending_date=ending_date,
due_date=prepaid_due_date)
# It is not possible to register many-to-many relationship before
# the two end-objects are saved in database.
for order in bill_orders:
order.bill.add(next_yearly_bill)
logger.info("Generated yearly bill {} (amount: {}) for user {}."
.format(next_yearly_bill.uuid, next_yearly_bill.total, user))
# Add to output.
generated_bills.append(next_yearly_bill)
2020-03-05 15:22:41 +00:00
# Return generated (monthly + yearly) bills.
return generated_bills
@staticmethod
def get_unpaid_for(user):
balance = get_balance_for_user(user)
unpaid_bills = []
# No unpaid bill if balance is positive.
if balance >= 0:
return unpaid_bills
else:
bills = Bill.objects.filter(
owner=user,
).order_by('-creation_date')
# Amount to be paid by the customer.
unpaid_balance = abs(balance)
for bill in bills:
if unpaid_balance <= 0:
break
unpaid_balance -= bill.total
unpaid_bills.append(bill)
return unpaid_bills
@staticmethod
def get_overdue_for(user):
unpaid_bills = Bill.get_unpaid_for(user)
return list(filter(lambda bill: bill.due_date > timezone.now(), unpaid_bills))
class BillRecord():
"""
2020-05-07 12:24:04 +00:00
Entry of a bill, dynamically generated from an order.
"""
2020-05-07 12:24:04 +00:00
def __init__(self, bill, order):
self.bill = bill
2020-05-07 12:24:04 +00:00
self.order = order
2020-05-23 21:08:59 +00:00
self.recurring_price = order.recurring_price
2020-05-07 12:24:04 +00:00
self.recurring_period = order.recurring_period
self.description = order.description
2020-02-29 08:08:30 +00:00
if self.order.starting_date >= self.bill.starting_date:
2020-05-07 12:24:04 +00:00
self.one_time_price = order.one_time_price
else:
self.one_time_price = 0
# Set decimal context for amount computations.
# XXX: understand why we need +1 here.
decimal.getcontext().prec = AMOUNT_DECIMALS + 1
2020-03-03 10:15:48 +00:00
@property
def recurring_count(self):
# Compute billing delta.
billed_until = self.bill.ending_date
if self.order.ending_date != None and self.order.ending_date <= self.bill.ending_date:
billed_until = self.order.ending_date
billed_from = self.bill.starting_date
if self.order.starting_date > self.bill.starting_date:
billed_from = self.order.starting_date
if billed_from > billed_until:
2020-03-05 15:22:41 +00:00
# TODO: think about and check edge cases. This should not be
# possible.
raise Exception('Impossible billing delta!')
billed_delta = billed_until - billed_from
# TODO: refactor this thing?
# TODO: weekly
# if self.recurring_period == RecurringPeriod.PER_YEAR:
# # XXX: Should always be one => we do not bill for more than one year.
# # TODO: check billed_delta is ~365 days.
# return 1
# elif self.recurring_period == RecurringPeriod.PER_MONTH:
# days = ceil(billed_delta / timedelta(days=1))
# # Monthly bills always cover one single month.
# if (self.bill.starting_date.year != self.bill.starting_date.year or
# self.bill.starting_date.month != self.bill.ending_date.month):
# raise Exception('Bill {} covers more than one month. Cannot bill PER_MONTH.'.
# format(self.bill.uuid))
# # XXX: minumal length of monthly order is to be enforced somewhere else.
# (_, days_in_month) = monthrange(
# self.bill.starting_date.year,
# self.bill.starting_date.month)
# return round(days / days_in_month, AMOUNT_DECIMALS)
if self.recurring_period == RecurringPeriod.PER_WEEK:
2020-03-05 15:22:41 +00:00
weeks = ceil(billed_delta / timedelta(week=1))
return weeks
elif self.recurring_period == RecurringPeriod.PER_DAY:
days = ceil(billed_delta / timedelta(days=1))
return days
elif self.recurring_period == RecurringPeriod.PER_HOUR:
hours = ceil(billed_delta / timedelta(hours=1))
return hours
elif self.recurring_period == RecurringPeriod.PER_SECOND:
seconds = ceil(billed_delta / timedelta(seconds=1))
return seconds
elif self.recurring_period == RecurringPeriod.ONE_TIME:
return 0
else:
raise Exception('Unsupported recurring period: {}.'.
2020-05-07 12:24:04 +00:00
format(self.order.recurring_period))
2020-02-29 08:08:30 +00:00
@property
def vat_rate(self):
return Decimal(VATRate.get_for_country(self.bill.billing_address.country))
@property
def vat_amount(self):
return self.amount * self.vat_rate
2020-03-03 10:15:48 +00:00
@property
def amount(self):
return Decimal(float(self.recurring_price) * self.recurring_count) + self.one_time_price
2020-03-03 09:59:21 +00:00
@property
def total(self):
return self.amount + self.vat_amount
###
# Orders.
2020-02-29 08:08:30 +00:00
# Order are assumed IMMUTABLE and used as SOURCE OF TRUST for generating
# bills. Do **NOT** mutate then!
2020-02-27 10:21:38 +00:00
class Order(models.Model):
uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
owner = models.ForeignKey(get_user_model(),
on_delete=models.CASCADE,
editable=False)
billing_address = models.ForeignKey(BillingAddress, on_delete=models.CASCADE)
2020-05-07 10:08:18 +00:00
description = models.TextField()
replaced_by = models.ForeignKey('self', on_delete=models.CASCADE, blank=True, null=True)
2020-02-27 10:21:38 +00:00
# TODO: enforce ending_date - starting_date to be larger than recurring_period.
creation_date = models.DateTimeField(auto_now_add=True)
starting_date = models.DateTimeField(default=timezone.now)
ending_date = models.DateTimeField(blank=True,
null=True)
bill = models.ManyToManyField(Bill,
editable=False,
blank=True)
recurring_period = models.IntegerField(choices = RecurringPeriod.choices, default = RecurringPeriod.PER_30D)
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)])
replaced_by = models.ForeignKey('self',
2020-05-08 14:47:32 +00:00
related_name='supersede',
on_delete=models.PROTECT,
blank=True,
null=True)
2020-05-08 14:47:32 +00:00
depends_on = models.ForeignKey('self',
related_name='parent_of',
on_delete=models.PROTECT,
blank=True,
null=True)
2020-05-23 19:32:56 +00:00
def active_before(self, ending_date):
# Was this order started before the specified ending date?
if self.starting_date <= ending_date:
if self.ending_date:
if self.ending_date > ending_date:
2020-05-23 21:08:59 +00:00
pass
2020-05-23 19:32:56 +00:00
@property
def is_recurring(self):
return not self.recurring_period == RecurringPeriod.ONE_TIME
2020-05-08 07:31:46 +00:00
@property
def is_terminated(self):
return self.ending_date != None and self.ending_date < timezone.now()
2020-05-23 19:32:56 +00:00
def is_terminated_at(self, a_date):
return self.ending_date != None and self.ending_date < timezone.now()
2020-05-08 07:31:46 +00:00
def terminate(self):
if not self.is_terminated:
self.ending_date = timezone.now()
self.save()
2020-05-23 21:08:59 +00:00
def is_to_be_charged_in(year, month):
pass
2020-05-23 21:08:59 +00:00
# Trigger initial bill generation at order creation.
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)
2020-05-08 09:33:59 +00:00
def generate_initial_bill(self):
return Bill.generate_for(self.starting_date.year, self.starting_date.month, self.owner)
2020-05-08 09:33:59 +00:00
# Used by uncloud_pay tests.
@property
def bills(self):
return Bill.objects.filter(order=self)
2020-04-18 11:51:31 +00:00
@staticmethod
def from_product(product, **kwargs):
# FIXME: this is only a workaround.
billing_address = BillingAddress.get_preferred_address_for(product.owner)
if billing_address == None:
raise Exception("Owner does not have a billing address!")
return Order(description=product.description,
one_time_price=product.one_time_price,
recurring_price=product.recurring_price,
billing_address=billing_address,
owner=product.owner,
**kwargs)
def __str__(self):
2020-05-02 21:44:20 +00:00
return "Order {} created at {}, {}->{}, recurring period {}. One time price {}, recurring price {}".format(
self.uuid, self.creation_date,
self.starting_date, self.ending_date,
self.recurring_period,
self.one_time_price,
self.recurring_price)
class OrderTimothee(models.Model):
uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
owner = models.ForeignKey(get_user_model(),
on_delete=models.CASCADE,
editable=False)
billing_address = models.ForeignKey(BillingAddress, on_delete=models.CASCADE)
# TODO: enforce ending_date - starting_date to be larger than recurring_period.
creation_date = models.DateTimeField(auto_now_add=True)
starting_date = models.DateTimeField(default=timezone.now)
2020-02-27 10:21:38 +00:00
ending_date = models.DateTimeField(blank=True,
null=True)
bill = models.ManyToManyField(Bill,
editable=False,
blank=True)
2020-02-27 10:21:38 +00:00
recurring_period = models.IntegerField(choices = RecurringPeriod.choices,
default = RecurringPeriod.PER_30D)
2020-02-27 10:21:38 +00:00
# Trigger initial bill generation at order creation.
def save(self, *args, **kwargs):
2020-05-02 18:31:36 +00:00
if self.ending_date and self.ending_date < self.starting_date:
raise ValidationError("End date cannot be before starting date")
super().save(*args, **kwargs)
Bill.generate_for(self.starting_date.year, self.starting_date.month, self.owner)
2020-03-02 08:25:03 +00:00
@property
def records(self):
return OrderRecord.objects.filter(order=self)
@property
def one_time_price(self):
return reduce(lambda acc, record: acc + record.one_time_price, self.records, 0)
2020-02-29 08:08:30 +00:00
2020-03-01 14:47:27 +00:00
@property
2020-03-02 08:25:03 +00:00
def recurring_price(self):
return reduce(lambda acc, record: acc + record.recurring_price, self.records, 0)
# Used by uncloud_pay tests.
@property
def bills(self):
return Bill.objects.filter(order=self)
def add_record(self, one_time_price, recurring_price, description):
OrderRecord.objects.create(order=self,
one_time_price=one_time_price,
recurring_price=recurring_price,
description=description)
def __str__(self):
return "Order {} created at {}, {}->{}, recurring period {}. Price one time {}, recurring {}".format(
self.uuid, self.creation_date,
self.starting_date, self.ending_date,
self.recurring_period,
self.one_time_price,
self.recurring_price)
2020-03-02 07:09:42 +00:00
class OrderRecord(models.Model):
2020-03-03 10:34:47 +00:00
"""
Order records store billing informations for products: the actual product
might be mutated and/or moved to another order but we do not want to loose
the details of old orders.
Used as source of trust to dynamically generate bill entries.
"""
2020-03-02 07:09:42 +00:00
order = models.ForeignKey(Order, on_delete=models.CASCADE)
one_time_price = models.DecimalField(default=0.0,
2020-03-02 07:09:42 +00:00
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)])
description = models.TextField()
2020-02-27 10:21:38 +00:00
2020-03-21 10:59:04 +00:00
2020-03-02 07:09:42 +00:00
@property
def recurring_period(self):
return self.order.recurring_period
2020-02-27 10:21:38 +00:00
@property
def starting_date(self):
return self.order.starting_date
@property
def ending_date(self):
return self.order.ending_date
2020-02-27 10:21:38 +00:00
###
# Products
# Abstract (= no database representation) class used as parent for products
# (e.g. uncloud_vm.models.VMProduct).
2020-03-21 10:59:04 +00:00
class Product(UncloudModel):
uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
owner = models.ForeignKey(get_user_model(),
on_delete=models.CASCADE,
editable=False)
description = "Generic Product"
status = models.CharField(max_length=32,
2020-03-22 19:55:11 +00:00
choices=UncloudStatus.choices,
default=UncloudStatus.AWAITING_PAYMENT)
order = models.ForeignKey(Order,
on_delete=models.CASCADE,
editable=False,
null=True)
# Default period for all products
default_recurring_period = RecurringPeriod.PER_30D
# Used to save records.
def save(self, *args, **kwargs):
# _state.adding is switched to false after super(...) call.
being_created = self._state.adding
# First time saving - create an order
if not self.order:
billing_address = BillingAddress.get_preferred_address_for(self.owner)
print(billing_address)
if not billing_address:
raise ValidationError("Cannot order without a billing address")
2020-05-07 18:22:42 +00:00
# FIXME: allow user to choose recurring_period
self.order = Order.objects.create(owner=self.owner,
2020-05-02 21:44:20 +00:00
billing_address=billing_address,
one_time_price=self.one_time_price,
2020-05-07 18:22:42 +00:00
recurring_period=self.default_recurring_period,
2020-05-02 21:44:20 +00:00
recurring_price=self.recurring_price)
super().save(*args, **kwargs)
# # Make sure we only create records on creation.
# if being_created:
# record = OrderRecord(
# one_time_price=self.one_time_price,
# recurring_price=self.recurring_price,
# description=self.description)
# self.order.orderrecord_set.add(record, bulk=False)
@property
def recurring_price(self):
pass # To be implemented in child.
@property
def one_time_price(self):
return 0
@property
def billing_address(self):
return self.order.billing_address
2020-03-03 09:51:16 +00:00
@staticmethod
def allowed_recurring_periods():
return RecurringPeriod.choices
class Meta:
abstract = True
def discounted_price_by_period(self, requested_period):
"""
Each product has a standard recurring period for which
we define a pricing. I.e. VPN is usually year, VM is usually monthly.
The user can opt-in to use a different period, which influences the price:
The longer a user commits, the higher the discount.
Products can also be limited in the available periods. For instance
a VPN only makes sense to be bought for at least one day.
Rules are as follows:
given a standard recurring period of ..., changing to ... modifies price ...
# One month for free if buying / year, compared to a month: about 8.33% discount
per_year -> per_month -> /11
per_month -> per_year -> *11
# Month has 30.42 days on average. About 7.9% discount to go monthly
per_month -> per_day -> /28
per_day -> per_month -> *28
# Day has 24h, give one for free
per_day -> per_hour -> /23
per_hour -> per_day -> /23
Examples
VPN @ 120CHF/y becomes
- 10.91 CHF/month (130.91 CHF/year)
- 0.39 CHF/day (142.21 CHF/year)
VM @ 15 CHF/month becomes
- 165 CHF/month (13.75 CHF/month)
- 0.54 CHF/day (16.30 CHF/month)
"""
if self.default_recurring_period == RecurringPeriod.PER_365D:
if requested_period == RecurringPeriod.PER_365D:
return self.recurring_price
if requested_period == RecurringPeriod.PER_30D:
return self.recurring_price/11.
if requested_period == RecurringPeriod.PER_DAY:
return self.recurring_price/11./28.
elif self.default_recurring_period == RecurringPeriod.PER_30D:
if requested_period == RecurringPeriod.PER_365D:
return self.recurring_price*11
if requested_period == RecurringPeriod.PER_30D:
return self.recurring_price
if requested_period == RecurringPeriod.PER_DAY:
return self.recurring_price/28.
elif self.default_recurring_period == RecurringPeriod.PER_DAY:
if requested_period == RecurringPeriod.PER_365D:
return self.recurring_price*11*28
if requested_period == RecurringPeriod.PER_30D:
return self.recurring_price*28
if requested_period == RecurringPeriod.PER_DAY:
return self.recurring_price
else:
# FIXME: use the right type of exception here!
raise Exception("Did not implement the discounter for this case")