Merge branch 'uncloud-product-activation' into 'master'

Handle product activation on payment

See merge request uncloud/uncloud!6
This commit is contained in:
fnux 2020-04-18 09:08:32 +02:00
commit 7afb3f8793
16 changed files with 352 additions and 38 deletions

View File

@ -44,6 +44,8 @@ router.register(r'vm/vm', vmviews.VMProductViewSet, basename='vmproduct')
# Services
router.register(r'service/matrix', serviceviews.MatrixServiceProductViewSet, basename='matrixserviceproduct')
router.register(r'service/generic', serviceviews.GenericServiceProductViewSet, basename='genericserviceproduct')
# Net
router.register(r'net/vpn', netviews.VPNNetworkViewSet, basename='vpnnet')

View File

@ -0,0 +1,18 @@
# Generated by Django 3.0.5 on 2020-04-17 05:51
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('uncloud_net', '0002_auto_20200409_1225'),
]
operations = [
migrations.AlterField(
model_name='vpnnetwork',
name='status',
field=models.CharField(choices=[('PENDING', 'Pending'), ('AWAITING_PAYMENT', 'Awaiting payment'), ('BEING_CREATED', 'Being created'), ('SCHEDULED', 'Scheduled'), ('ACTIVE', 'Active'), ('MODIFYING', 'Modifying'), ('DELETED', 'Deleted'), ('DISABLED', 'Disabled'), ('UNUSABLE', 'Unusable')], default='AWAITING_PAYMENT', max_length=32),
),
]

View File

@ -1,6 +1,6 @@
from django.core.management.base import BaseCommand
from uncloud_auth.models import User
from uncloud_pay.models import Order, Bill, PaymentMethod, get_balance_for
from uncloud_pay.models import Order, Bill, PaymentMethod, get_balance_for_user
from datetime import timedelta
from django.utils import timezone
@ -15,7 +15,7 @@ class Command(BaseCommand):
users = User.objects.all()
print("Processing {} users.".format(users.count()))
for user in users:
balance = get_balance_for(user)
balance = get_balance_for_user(user)
if balance < 0:
print("User {} has negative balance ({}), charging.".format(user.username, balance))
payment_method = PaymentMethod.get_primary_for(user)

View File

@ -92,19 +92,19 @@ class Payment(models.Model):
default='unknown')
timestamp = models.DateTimeField(editable=False, auto_now_add=True)
# WIP prepaid and service activation logic by fnux.
## We override save() in order to active products awaiting payment.
#def save(self, *args, **kwargs):
# # TODO: only run activation logic on creation, not on update.
# 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)
# 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
# newly_paid_bills = list(
# set(unpaid_bills_before_payment) - set(unpaid_bills_after_payment))
# for bill in newly_paid_bills:
# bill.activate_orders()
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()
class PaymentMethod(models.Model):
uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
@ -201,6 +201,12 @@ class Bill(models.Model):
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()
@property
def reference(self):
return "{}-{}".format(
@ -227,6 +233,15 @@ class Bill(models.Model):
# A bill is final when its ending date is passed.
return self.ending_date < timezone.now()
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()
@staticmethod
def generate_for(year, month, user):
# /!\ We exclusively work on the specified year and month.
@ -248,7 +263,7 @@ class Bill(models.Model):
# (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': {}}
unpaid_orders = { 'monthly_or_less': [], 'yearly': {} }
for order in orders:
try:
previous_bill = order.bill.latest('ending_date')
@ -276,7 +291,7 @@ class Bill(models.Model):
else:
unpaid_orders['yearly'][next_yearly_bill_start_on] = bucket + [order]
else:
if previous_bill == None or previous_bill.ending_date <= ending_date:
if previous_bill == None or previous_bill.ending_date < ending_date:
unpaid_orders['monthly_or_less'].append(order)
# Handle working month's billing.
@ -335,25 +350,24 @@ class Bill(models.Model):
@staticmethod
def get_unpaid_for(user):
balance = get_balance_for(user)
balance = get_balance_for_user(user)
unpaid_bills = []
# No unpaid bill if balance is positive.
if balance >= 0:
return []
return unpaid_bills
else:
bills = Bill.objects.filter(
owner=user,
due_date__lt=timezone.now()
).order_by('-creation_date')
# Amount to be paid by the customer.
unpaid_balance = abs(balance)
for bill in bills:
if unpaid_balance < 0:
if unpaid_balance <= 0:
break
unpaid_balance -= bill.amount
unpaid_bills.append(bill)
unpaid_balance -= bill.total
unpaid_bills.append(bill)
return unpaid_bills
@ -464,6 +478,11 @@ class Order(models.Model):
choices = RecurringPeriod.choices,
default = RecurringPeriod.PER_MONTH)
# Trigger initial bill generation at order creation.
def save(self, *args, **kwargs):
super(Order, self).save(*args, **kwargs)
Bill.generate_for(self.starting_date.year, self.starting_date.month, self.owner)
@property
def records(self):
return OrderRecord.objects.filter(order=self)
@ -477,6 +496,10 @@ class Order(models.Model):
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,
@ -531,11 +554,11 @@ class Product(UncloudModel):
on_delete=models.CASCADE,
editable=False)
description = ""
description = "Generic Product"
status = models.CharField(max_length=32,
choices=UncloudStatus.choices,
default=UncloudStatus.PENDING)
default=UncloudStatus.AWAITING_PAYMENT)
order = models.ForeignKey(Order,
on_delete=models.CASCADE,
@ -556,7 +579,7 @@ class Product(UncloudModel):
if being_created:
record = OrderRecord(
one_time_price=self.one_time_price,
recurring_price=self.recurring_price(self.recurring_period),
recurring_price=self.recurring_price(recurring_period=self.recurring_period),
description=self.description)
self.order.orderrecord_set.add(record, bulk=False)

View File

@ -3,6 +3,7 @@ from django.contrib.auth import get_user_model
from datetime import datetime, date, timedelta
from .models import *
from uncloud_service.models import GenericServiceProduct
class BillingTestCase(TestCase):
def setUp(self):
@ -10,9 +11,6 @@ class BillingTestCase(TestCase):
username='jdoe',
email='john.doe@domain.tld')
def test_truth(self):
self.assertEqual(1+1, 2)
def test_basic_monthly_billing(self):
one_time_price = 10
recurring_price = 20
@ -31,7 +29,7 @@ class BillingTestCase(TestCase):
order.add_record(one_time_price, recurring_price, description)
# Generate & check bill for first month: full recurring_price + setup.
first_month_bills = Bill.generate_for(2020, 3, self.user)
first_month_bills = order.bills # Initial bill generated at order creation.
self.assertEqual(len(first_month_bills), 1)
self.assertEqual(first_month_bills[0].total, one_time_price + recurring_price)
@ -65,7 +63,7 @@ class BillingTestCase(TestCase):
order.add_record(one_time_price, recurring_price, description)
# Generate & check bill for first year: recurring_price + setup.
first_year_bills = Bill.generate_for(2020, 3, self.user)
first_year_bills = order.bills # Initial bill generated at order creation.
self.assertEqual(len(first_year_bills), 1)
self.assertEqual(first_year_bills[0].starting_date.date(),
date.fromisoformat('2020-03-31'))
@ -106,7 +104,7 @@ class BillingTestCase(TestCase):
order.add_record(one_time_price, recurring_price, description)
# Generate & check bill for first month: recurring_price + setup.
first_month_bills = Bill.generate_for(2020, 3, self.user)
first_month_bills = order.bills
self.assertEqual(len(first_month_bills), 1)
self.assertEqual(float(first_month_bills[0].total),
round(16 * recurring_price, AMOUNT_DECIMALS) + one_time_price)
@ -116,3 +114,41 @@ class BillingTestCase(TestCase):
self.assertEqual(len(second_month_bills), 1)
self.assertEqual(float(second_month_bills[0].total),
round(12 * recurring_price, AMOUNT_DECIMALS))
class ProductActivationTestCase(TestCase):
def setUp(self):
self.user = get_user_model().objects.create(
username='jdoe',
email='john.doe@domain.tld')
def test_product_activation(self):
starting_date = datetime.fromisoformat('2020-03-01')
order = Order.objects.create(
owner=self.user,
starting_date=starting_date,
recurring_period=RecurringPeriod.PER_MONTH)
order.save()
product = GenericServiceProduct(
custom_description="Test product",
custom_one_time_price=0,
custom_recurring_price=20,
owner=self.user,
order=order)
product.save()
# XXX: to be automated.
order.add_record(product.one_time_price, product.recurring_price(), product.description)
# Validate initial state: must be awaiting payment.
self.assertEqual(product.status, UncloudStatus.AWAITING_PAYMENT)
# Pay initial bill, check that product is activated.
amount = product.order.bills[0].total
payment = Payment(owner=self.user, amount=amount)
payment.save()
self.assertEqual(
GenericServiceProduct.objects.get(uuid=product.uuid).status,
UncloudStatus.PENDING
)

View File

@ -0,0 +1,41 @@
# Generated by Django 3.0.5 on 2020-04-18 06:41
from django.conf import settings
import django.contrib.postgres.fields.jsonb
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('uncloud_pay', '0005_auto_20200413_0924'),
('uncloud_service', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='matrixserviceproduct',
name='status',
field=models.CharField(choices=[('PENDING', 'Pending'), ('AWAITING_PAYMENT', 'Awaiting payment'), ('BEING_CREATED', 'Being created'), ('SCHEDULED', 'Scheduled'), ('ACTIVE', 'Active'), ('MODIFYING', 'Modifying'), ('DELETED', 'Deleted'), ('DISABLED', 'Disabled'), ('UNUSABLE', 'Unusable')], default='AWAITING_PAYMENT', max_length=32),
),
migrations.CreateModel(
name='GenericServiceProduct',
fields=[
('extra_data', django.contrib.postgres.fields.jsonb.JSONField(blank=True, editable=False, null=True)),
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('status', models.CharField(choices=[('PENDING', 'Pending'), ('AWAITING_PAYMENT', 'Awaiting payment'), ('BEING_CREATED', 'Being created'), ('SCHEDULED', 'Scheduled'), ('ACTIVE', 'Active'), ('MODIFYING', 'Modifying'), ('DELETED', 'Deleted'), ('DISABLED', 'Disabled'), ('UNUSABLE', 'Unusable')], default='AWAITING_PAYMENT', max_length=32)),
('custom_description', models.TextField()),
('custom_recurring_price', models.DecimalField(decimal_places=2, default=0.0, max_digits=10, validators=[django.core.validators.MinValueValidator(0)])),
('custom_one_time_price', models.DecimalField(decimal_places=2, default=0.0, max_digits=10, validators=[django.core.validators.MinValueValidator(0)])),
('order', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.Order')),
('owner', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
options={
'abstract': False,
},
),
]

View File

@ -1,8 +1,9 @@
import uuid
from django.db import models
from uncloud_pay.models import Product, RecurringPeriod
from uncloud_pay.models import Product, RecurringPeriod, AMOUNT_MAX_DIGITS, AMOUNT_DECIMALS
from uncloud_vm.models import VMProduct, VMDiskImageProduct
from django.core.validators import MinValueValidator
class MatrixServiceProduct(Product):
monthly_managment_fee = 20
@ -16,7 +17,7 @@ class MatrixServiceProduct(Product):
domain = models.CharField(max_length=255, default='domain.tld')
# Default recurring price is PER_MONT, see Product class.
def recurring_price(self):
def recurring_price(self, recurring_period=RecurringPeriod.PER_MONTH):
return self.monthly_managment_fee
@staticmethod
@ -33,3 +34,30 @@ class MatrixServiceProduct(Product):
@property
def one_time_price(self):
return 30
class GenericServiceProduct(Product):
custom_description = models.TextField()
custom_recurring_price = models.DecimalField(default=0.0,
max_digits=AMOUNT_MAX_DIGITS,
decimal_places=AMOUNT_DECIMALS,
validators=[MinValueValidator(0)])
custom_one_time_price = models.DecimalField(default=0.0,
max_digits=AMOUNT_MAX_DIGITS,
decimal_places=AMOUNT_DECIMALS,
validators=[MinValueValidator(0)])
def recurring_price(self, recurring_period=RecurringPeriod.PER_MONTH):
# FIXME: handle recurring_period somehow.
return self.custom_recurring_price
@property
def description(self):
return self.custom_description
@property
def one_time_price(self):
return self.custom_one_time_price
@staticmethod
def allowed_recurring_periods():
return RecurringPeriod.choices

View File

@ -1,5 +1,5 @@
from rest_framework import serializers
from .models import MatrixServiceProduct
from .models import *
from uncloud_vm.serializers import ManagedVMProductSerializer
from uncloud_vm.models import VMProduct
from uncloud_pay.models import RecurringPeriod
@ -15,3 +15,14 @@ class MatrixServiceProductSerializer(serializers.ModelSerializer):
model = MatrixServiceProduct
fields = ['uuid', 'order', 'owner', 'status', 'vm', 'domain', 'recurring_period']
read_only_fields = ['uuid', 'order', 'owner', 'status']
class GenericServiceProductSerializer(serializers.ModelSerializer):
# Custom field used at creation (= ordering) only.
recurring_period = serializers.ChoiceField(
choices=GenericServiceProduct.allowed_recurring_periods())
class Meta:
model = GenericServiceProduct
fields = ['uuid', 'order', 'owner', 'status', 'custom_recurring_price',
'custom_description', 'custom_one_time_price', 'recurring_period']
read_only_fields = ['uuid', 'order', 'owner', 'status']

View File

@ -1,9 +1,10 @@
from rest_framework import viewsets, permissions
from rest_framework.response import Response
from django.db import transaction
from django.utils import timezone
from .models import MatrixServiceProduct
from .serializers import MatrixServiceProductSerializer
from .models import *
from .serializers import *
from uncloud_pay.helpers import ProductViewSet
from uncloud_pay.models import Order
@ -47,7 +48,10 @@ class MatrixServiceProductViewSet(ProductViewSet):
# Create base order.)
order = Order.objects.create(
recurring_period=order_recurring_period,
owner=request.user)
owner=request.user,
starting_date=timezone.now()
)
order.save()
# Create unerderlying VM.
data = serializer.validated_data.pop('vm')
@ -65,3 +69,45 @@ class MatrixServiceProductViewSet(ProductViewSet):
vm=vm)
return Response(serializer.data)
class GenericServiceProductViewSet(ProductViewSet):
permission_classes = [permissions.IsAuthenticated]
serializer_class = GenericServiceProductSerializer
def get_queryset(self):
return GenericServiceProduct.objects.filter(owner=self.request.user)
@transaction.atomic
def create(self, request):
# Extract serializer data.
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
order_recurring_period = serializer.validated_data.pop("recurring_period")
# Create base order.
order = Order.objects.create(
recurring_period=order_recurring_period,
owner=request.user,
starting_date=timezone.now()
)
order.save()
# Create service.
print(serializer.validated_data)
service = serializer.save(order=order, owner=request.user)
# XXX: Move this to some kind of on_create hook in parent
# Product class?
order.add_record(
service.one_time_price,
service.recurring_price,
service.description)
# XXX: Move this to some kind of on_create hook in parent
# Product class?
order.add_record(
service.one_time_price,
service.recurring_price,
service.description)
return Response(serializer.data)

View File

@ -7,7 +7,7 @@ import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('uncloud_vm', '0003_remove_vmhost_vms'),
('uncloud_vm', '0004_remove_vmproduct_vmid'),
]
operations = [

View File

@ -0,0 +1,23 @@
# Generated by Django 3.0.5 on 2020-04-17 05:51
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('uncloud_vm', '0008_auto_20200403_1727'),
]
operations = [
migrations.AlterField(
model_name='vmproduct',
name='status',
field=models.CharField(choices=[('PENDING', 'Pending'), ('AWAITING_PAYMENT', 'Awaiting payment'), ('BEING_CREATED', 'Being created'), ('SCHEDULED', 'Scheduled'), ('ACTIVE', 'Active'), ('MODIFYING', 'Modifying'), ('DELETED', 'Deleted'), ('DISABLED', 'Disabled'), ('UNUSABLE', 'Unusable')], default='AWAITING_PAYMENT', max_length=32),
),
migrations.AlterField(
model_name='vmsnapshotproduct',
name='status',
field=models.CharField(choices=[('PENDING', 'Pending'), ('AWAITING_PAYMENT', 'Awaiting payment'), ('BEING_CREATED', 'Being created'), ('SCHEDULED', 'Scheduled'), ('ACTIVE', 'Active'), ('MODIFYING', 'Modifying'), ('DELETED', 'Deleted'), ('DISABLED', 'Disabled'), ('UNUSABLE', 'Unusable')], default='AWAITING_PAYMENT', max_length=32),
),
]

View File

@ -0,0 +1,14 @@
# Generated by Django 3.0.5 on 2020-04-18 06:41
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('uncloud_vm', '0009_auto_20200417_0551'),
('uncloud_vm', '0010_auto_20200413_0924'),
]
operations = [
]

View File

@ -0,0 +1,18 @@
# Generated by Django 3.0.5 on 2020-04-18 06:41
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('uncloud_vm', '0011_merge_20200418_0641'),
]
operations = [
migrations.AlterField(
model_name='vmdiskproduct',
name='status',
field=models.CharField(choices=[('PENDING', 'Pending'), ('AWAITING_PAYMENT', 'Awaiting payment'), ('BEING_CREATED', 'Being created'), ('SCHEDULED', 'Scheduled'), ('ACTIVE', 'Active'), ('MODIFYING', 'Modifying'), ('DELETED', 'Deleted'), ('DISABLED', 'Disabled'), ('UNUSABLE', 'Unusable')], default='AWAITING_PAYMENT', max_length=32),
),
]

View File

@ -116,7 +116,7 @@ class VMProductViewSet(ProductViewSet):
order_recurring_period = serializer.validated_data.pop("recurring_period")
# Create base order.
order = Order.objects.create(
order = Order(
recurring_period=order_recurring_period,
owner=request.user,
starting_date=timezone.now()

View File

@ -0,0 +1,18 @@
# Generated by Django 3.0.5 on 2020-04-17 05:51
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('ungleich_service', '0004_auto_20200403_1727'),
]
operations = [
migrations.AlterField(
model_name='matrixserviceproduct',
name='status',
field=models.CharField(choices=[('PENDING', 'Pending'), ('AWAITING_PAYMENT', 'Awaiting payment'), ('BEING_CREATED', 'Being created'), ('SCHEDULED', 'Scheduled'), ('ACTIVE', 'Active'), ('MODIFYING', 'Modifying'), ('DELETED', 'Deleted'), ('DISABLED', 'Disabled'), ('UNUSABLE', 'Unusable')], default='AWAITING_PAYMENT', max_length=32),
),
]

View File

@ -0,0 +1,36 @@
# Generated by Django 3.0.5 on 2020-04-17 08:02
from django.conf import settings
import django.contrib.postgres.fields.jsonb
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('uncloud_pay', '0005_auto_20200417_0551'),
('ungleich_service', '0005_auto_20200417_0551'),
]
operations = [
migrations.CreateModel(
name='GenericServiceProduct',
fields=[
('extra_data', django.contrib.postgres.fields.jsonb.JSONField(blank=True, editable=False, null=True)),
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('status', models.CharField(choices=[('PENDING', 'Pending'), ('AWAITING_PAYMENT', 'Awaiting payment'), ('BEING_CREATED', 'Being created'), ('SCHEDULED', 'Scheduled'), ('ACTIVE', 'Active'), ('MODIFYING', 'Modifying'), ('DELETED', 'Deleted'), ('DISABLED', 'Disabled'), ('UNUSABLE', 'Unusable')], default='AWAITING_PAYMENT', max_length=32)),
('custom_description', models.TextField()),
('custom_recurring_price', models.DecimalField(decimal_places=2, default=0.0, max_digits=10, validators=[django.core.validators.MinValueValidator(0)])),
('custom_one_time_price', models.DecimalField(decimal_places=2, default=0.0, max_digits=10, validators=[django.core.validators.MinValueValidator(0)])),
('order', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.Order')),
('owner', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
options={
'abstract': False,
},
),
]