- Added PricingPlan Model

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

View file

@ -4,7 +4,6 @@ from django.urls import path
from django.shortcuts import render
from django.conf.urls import url
from uncloud_pay.views import BillViewSet
from hardcopy import bytestring_to_pdf
from django.core.files.temp import NamedTemporaryFile
from django.http import FileResponse
@ -90,14 +89,15 @@ admin.site.register(Bill, BillAdmin)
admin.site.register(Product, ProductAdmin)
for m in [
BillRecord,
BillingAddress,
Order,
BillRecord,
Payment,
ProductToRecurringPeriod,
RecurringPeriod,
StripeCreditCard,
StripeCustomer,
VATRate,
PricingPlan,
VATRate
]:
admin.site.register(m)

View file

@ -1,6 +1,7 @@
from django.core.management.base import BaseCommand
from uncloud_auth.models import User
from uncloud_pay.models import Order, Bill, PaymentMethod, get_balance_for_user
from uncloud_pay.models import Order, Bill, get_balance_for_user
import uncloud_pay.stripe as uncloud_stripe
from datetime import timedelta
from django.utils import timezone
@ -18,14 +19,10 @@ class Command(BaseCommand):
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)
if payment_method != None:
amount_to_be_charged = abs(balance)
charge_ok = payment_method.charge(amount_to_be_charged)
if not charge_ok:
print("ERR: charging {} with method {} failed"
.format(user.username, payment_method.uuid)
)
else:
print("ERR: no payment method registered for {}".format(user.username))
amount_to_be_charged = abs(balance)
result = uncloud_stripe.charge_customer(user, amount_to_be_charged)
if result.status != 'succeeded':
print("ERR: charging {} with method {} failed"
.format(user.username, result)
)
print("=> Done.")

View file

@ -1,11 +1,14 @@
from django.core.management.base import BaseCommand
from uncloud_pay.models import VATRate
import logging
import urllib
import csv
import sys
import io
logger = logging.getLogger(__name__)
class Command(BaseCommand):
help = '''Imports VAT Rates. Assume vat rates of format https://github.com/kdeldycke/vat-rates/blob/master/vat_rates.csv'''
vat_url = "https://raw.githubusercontent.com/ungleich/vat-rates/main/vat_rates.csv"
@ -23,13 +26,25 @@ class Command(BaseCommand):
reader = csv.DictReader(csv_file)
for row in reader:
# print(row)
obj, created = VATRate.objects.get_or_create(
starting_date=row["start_date"],
ending_date=row["stop_date"] if row["stop_date"] != "" else None,
territory_codes=row["territory_codes"],
currency_code=row["currency_code"],
rate=row["rate"],
rate_type=row["rate_type"],
description=row["description"]
)
if row["territory_codes"] and len(row["territory_codes"].splitlines()) > 1:
for code in row["territory_codes"].splitlines():
VATRate.objects.get_or_create(
starting_date=row["start_date"],
ending_date=row["stop_date"] if row["stop_date"] != "" else None,
territory_codes=code,
currency_code=row["currency_code"],
rate=row["rate"],
rate_type=row["rate_type"],
description=row["description"]
)
else:
VATRate.objects.get_or_create(
starting_date=row["start_date"],
ending_date=row["stop_date"] if row["stop_date"] != "" else None,
territory_codes=row["territory_codes"],
currency_code=row["currency_code"],
rate=row["rate"],
rate_type=row["rate_type"],
description=row["description"]
)
logger.info('All VAT Rates have been added!')

View file

@ -0,0 +1,23 @@
# Generated by Django 3.2.4 on 2021-06-30 07:42
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('uncloud_pay', '0011_auto_20210101_1308'),
]
operations = [
migrations.AddField(
model_name='billingaddress',
name='vat_number_verified',
field=models.BooleanField(default=False),
),
migrations.AlterField(
model_name='payment',
name='source',
field=models.CharField(choices=[('wire', 'Wire Transfer'), ('stripe', 'Stripe'), ('voucher', 'Voucher'), ('referral', 'Referral')], max_length=256),
),
]

View file

@ -0,0 +1,21 @@
# Generated by Django 3.2.4 on 2021-07-03 15:23
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('uncloud_pay', '0012_auto_20210630_0742'),
]
operations = [
migrations.AlterField(
model_name='billingaddress',
name='owner',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='billing_addresses', to=settings.AUTH_USER_MODEL),
),
]

View file

@ -0,0 +1,23 @@
# Generated by Django 3.2.4 on 2021-07-03 17:47
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('uncloud_pay', '0013_alter_billingaddress_owner'),
]
operations = [
migrations.AddField(
model_name='billingaddress',
name='stripe_tax_id',
field=models.CharField(blank=True, default='', max_length=100),
),
migrations.AddField(
model_name='billingaddress',
name='vat_number_validated_on',
field=models.DateTimeField(blank=True, null=True),
),
]

View file

@ -0,0 +1,34 @@
# Generated by Django 3.2.4 on 2021-07-05 08:49
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('uncloud_pay', '0014_auto_20210703_1747'),
]
operations = [
migrations.AddField(
model_name='order',
name='customer',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.stripecustomer'),
),
migrations.AddField(
model_name='order',
name='status',
field=models.CharField(choices=[('draft', 'Draft'), ('declined', 'Declined'), ('approved', 'Approved')], default='draft', max_length=100),
),
migrations.AddField(
model_name='order',
name='stripe_charge_id',
field=models.CharField(max_length=100, null=True),
),
migrations.AddField(
model_name='order',
name='vm_id',
field=models.IntegerField(default=0),
),
]

View file

@ -0,0 +1,29 @@
# Generated by Django 3.2.4 on 2021-07-06 13:21
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('uncloud_pay', '0015_auto_20210705_0849'),
]
operations = [
migrations.CreateModel(
name='PricingPlan',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255, unique=True)),
('vat_inclusive', models.BooleanField(default=True)),
('vat_percentage', models.DecimalField(blank=True, decimal_places=5, default=0, max_digits=7)),
('set_up_fees', models.DecimalField(decimal_places=2, default=0, max_digits=7)),
('cores_unit_price', models.DecimalField(decimal_places=2, default=0, max_digits=7)),
('ram_unit_price', models.DecimalField(decimal_places=2, default=0, max_digits=7)),
('storage_unit_price', models.DecimalField(decimal_places=2, default=0, max_digits=7)),
('discount_name', models.CharField(blank=True, max_length=255, null=True)),
('discount_amount', models.DecimalField(decimal_places=2, default=0, max_digits=6)),
('stripe_coupon_id', models.CharField(blank=True, max_length=255, null=True)),
],
),
]

View file

@ -0,0 +1,23 @@
# Generated by Django 3.2.4 on 2021-07-06 17:28
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('uncloud_pay', '0016_pricingplan'),
]
operations = [
migrations.RemoveField(
model_name='paymentmethod',
name='owner',
),
migrations.DeleteModel(
name='Payment',
),
migrations.DeleteModel(
name='PaymentMethod',
),
]

View file

@ -0,0 +1,30 @@
# Generated by Django 3.2.4 on 2021-07-06 17:47
from django.conf import settings
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('uncloud_pay', '0017_auto_20210706_1728'),
]
operations = [
migrations.CreateModel(
name='Payment',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('amount', models.DecimalField(decimal_places=2, max_digits=10, validators=[django.core.validators.MinValueValidator(0)])),
('source', models.CharField(choices=[('wire', 'Wire Transfer'), ('stripe', 'Stripe'), ('voucher', 'Voucher'), ('referral', 'Referral')], max_length=256)),
('timestamp', models.DateTimeField(default=django.utils.timezone.now)),
('currency', models.CharField(choices=[('CHF', 'Swiss Franc')], default='CHF', max_length=32)),
('external_reference', models.CharField(blank=True, default='', max_length=256, null=True)),
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
]

View file

@ -0,0 +1,19 @@
# Generated by Django 3.2.4 on 2021-07-06 19:18
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('uncloud_pay', '0018_payment'),
]
operations = [
migrations.AddField(
model_name='order',
name='pricing_plan',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.pricingplan'),
),
]

View file

@ -0,0 +1,18 @@
# Generated by Django 3.2.4 on 2021-07-07 20:18
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('uncloud_pay', '0019_order_pricing_plan'),
]
operations = [
migrations.RenameField(
model_name='bill',
old_name='is_final',
new_name='is_closed',
),
]

View file

@ -0,0 +1,21 @@
# Generated by Django 3.2.4 on 2021-07-09 09:14
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('uncloud_pay', '0020_rename_is_final_bill_is_closed'),
]
operations = [
migrations.RemoveField(
model_name='order',
name='stripe_charge_id',
),
migrations.RemoveField(
model_name='order',
name='vm_id',
),
]

View file

@ -0,0 +1,17 @@
# Generated by Django 3.2.4 on 2021-07-11 08:32
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('uncloud_pay', '0021_auto_20210709_0914'),
]
operations = [
migrations.RemoveField(
model_name='order',
name='status',
),
]

View file

@ -1,5 +1,6 @@
import logging
import datetime
import json
from math import ceil
from calendar import monthrange
@ -9,18 +10,22 @@ from django.conf import settings
from django.contrib.auth import get_user_model
from django.core.validators import MinValueValidator
from django.db import models
from django.db.models import Q
from django.utils.translation import gettext_lazy as _
from django.utils import timezone
from django_q.tasks import schedule
from django_q.models import Schedule
# Verify whether or not to use them here
from django.core.exceptions import ObjectDoesNotExist, ValidationError
import uncloud_pay
from uncloud import AMOUNT_DECIMALS, AMOUNT_MAX_DIGITS
from uncloud.models import UncloudAddress
from uncloud.models import UncloudAddress, UncloudProvider
from uncloud.selectors import filter_for_when
from .services import *
# Used to generate bill due dates.
BILL_PAYMENT_DELAY=datetime.timedelta(days=10)
BILL_PAYMENT_DELAY=datetime.timedelta(days=settings.BILL_PAYMENT_DELAY)
# Initialize logger.
logger = logging.getLogger(__name__)
@ -96,84 +101,18 @@ class Payment(models.Model):
def __str__(self):
return f"{self.amount}{self.currency} from {self.owner} via {self.source} on {self.timestamp}"
###
# Payments and Payment Methods.
class PaymentMethod(models.Model):
"""
Not sure if this is still in use
"""
owner = models.ForeignKey(get_user_model(),
on_delete=models.CASCADE,
editable=False)
source = models.CharField(max_length=256,
choices = (
('stripe', 'Stripe'),
('unknown', 'Unknown'),
),
default='stripe')
description = models.TextField()
primary = models.BooleanField(default=False, editable=False)
# Only used for "Stripe" source
stripe_payment_method_id = models.CharField(max_length=32, blank=True, null=True)
stripe_setup_intent_id = models.CharField(max_length=32, blank=True, null=True)
@property
def active(self):
if self.source == 'stripe' and self.stripe_payment_method_id != None:
return True
else:
return False
def charge(self, amount):
if not self.active:
raise Exception('This payment method is inactive.')
if amount < 0: # Make sure we don't charge negative amount by errors...
raise Exception('Cannot charge negative amount.')
def save(self, *args, **kwargs):
# Try to charge the user via the active card before saving otherwise throw payment Error
if self.source == 'stripe':
stripe_customer = StripeCustomer.objects.get(owner=self.owner).stripe_id
stripe_payment = uncloud_pay.stripe.charge_customer(
amount, stripe_customer, self.stripe_payment_method_id)
if 'paid' in stripe_payment and stripe_payment['paid'] == False:
raise Exception(stripe_payment['error'])
else:
payment = Payment.objects.create(
owner=self.owner, source=self.source, amount=amount)
return payment
else:
raise Exception('This payment method is unsupported/cannot be charged.')
def set_as_primary_for(self, user):
methods = PaymentMethod.objects.filter(owner=user, primary=True)
for method in methods:
print(method)
method.primary = False
method.save()
self.primary = True
self.save()
def get_primary_for(user):
methods = PaymentMethod.objects.filter(owner=user)
for method in methods:
# Do we want to do something with non-primary method?
if method.active and method.primary:
return method
return None
class Meta:
# TODO: limit to one primary method per user.
# unique_together is no good since it won't allow more than one
# non-primary method.
pass
try:
result = uncloud_pay.stripe.charge_customer(self.owner, self.amount, self.currency,)
if not result.status or result.status != 'succeeded':
raise Exception("The payment has been failed, please try to activate another card")
super().save(*args, **kwargs)
except Exception as e:
raise e
# See https://docs.djangoproject.com/en/dev/ref/models/fields/#field-choices-enum-types
class RecurringPeriodDefaultChoices(models.IntegerChoices):
@ -231,9 +170,11 @@ class RecurringPeriod(models.Model):
# Bills.
class BillingAddress(UncloudAddress):
owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE)
owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE, related_name='billing_addresses')
vat_number = models.CharField(max_length=100, default="", blank=True)
vat_number_verified = models.BooleanField(default=False)
vat_number_validated_on = models.DateTimeField(blank=True, null=True)
stripe_tax_id = models.CharField(max_length=100, default="", blank=True)
active = models.BooleanField(default=False)
class Meta:
@ -272,6 +213,10 @@ class BillingAddress(UncloudAddress):
self.owner,
self.full_name, self.street, self.postal_code, self.city,
self.country)
@staticmethod
def get_address_for(user):
return BillingAddress.objects.get(owner=user)
###
# VAT
@ -297,10 +242,44 @@ class VATRate(models.Model):
logger.debug(str(dne))
logger.debug("Did not find VAT rate for %s, returning 0" % country_code)
return 0
@staticmethod
def get_vat_rate(billing_address, when=None):
"""
Returns the VAT rate for business to customer.
B2B is always 0% with the exception of trading within the own country
"""
country = billing_address.country
# Need to have a provider country
providers = UncloudProvider.objects.all()
vatrate = filter_for_when(VATRate.objects.filter(territory_codes=country), when).first()
if not providers and not vatrate:
return 0
uncloud_provider = filter_for_when(providers).get()
# By default we charge VAT. This affects:
# - Same country sales (VAT applied)
# - B2C to EU (VAT applied)
rate = vatrate.rate if vatrate else 0
# Exception: if...
# - the billing_address is in EU,
# - the vat_number has been set
# - the vat_number has been verified
# Then we do not charge VAT
if uncloud_provider.country != country and billing_address.vat_number and billing_address.vat_number_verified:
rate = 0
return rate
def __str__(self):
return f"{self.territory_codes}: {self.starting_date} - {self.ending_date}: {self.rate_type}"
return f"{self.territory_codes}: {self.starting_date} - {self.ending_date or ''}: {self.rate_type}"
###
# Products
@ -342,30 +321,20 @@ class Product(models.Model):
'features': {
'cores':
{ 'min': 1,
'max': 48,
'one_time_price_per_unit': 0,
'recurring_price_per_unit': 3
'max': 48
},
'ram_gb':
{ 'min': 1,
'max': 256,
'one_time_price_per_unit': 0,
'recurring_price_per_unit': 4
'max': 256
},
'ssd_gb':
{ 'min': 10,
'one_time_price_per_unit': 0,
'recurring_price_per_unit': 0.35
{ 'min': 10
},
'hdd_gb':
{ 'min': 0,
'one_time_price_per_unit': 0,
'recurring_price_per_unit': 15/1000
},
'additional_ipv4_address':
{ 'min': 0,
'one_time_price_per_unit': 0,
'recurring_price_per_unit': 8
},
}
}
@ -381,36 +350,23 @@ class Product(models.Model):
'base':
{ 'min': 1,
'max': 1,
'one_time_price_per_unit': 0,
'recurring_price_per_unit': 1
},
'cores':
{ 'min': 1,
'max': 48,
'one_time_price_per_unit': 0,
'recurring_price_per_unit': 3
},
'ram_gb':
{ 'min': 1,
'max': 256,
'one_time_price_per_unit': 0,
'recurring_price_per_unit': 4
},
'ssd_gb':
{ 'min': 10,
'one_time_price_per_unit': 0,
'recurring_price_per_unit': 0.35
{ 'min': 10
},
'hdd_gb':
{ 'min': 0,
'one_time_price_per_unit': 0,
'recurring_price_per_unit': 15/1000
{ 'min': 0
},
'additional_ipv4_address':
{ 'min': 0,
'one_time_price_per_unit': 0,
'recurring_price_per_unit': 9
},
{ 'min': 0,},
}
}
)
@ -433,7 +389,7 @@ class Product(models.Model):
@property
def recurring_orders(self):
return self.orders.order_by('id').exclude(recurring_period=RecurringPeriod.objects.get(name="ONE_TIME"))
return self.orders.order_by('id').exclude(recurring_price=0)
@property
def last_recurring_order(self):
@ -441,56 +397,12 @@ class Product(models.Model):
@property
def one_time_orders(self):
return self.orders.order_by('id').filter(recurring_period=RecurringPeriod.objects.get(name="ONE_TIME"))
return self.orders.order_by('id').filter(recurring_price=0)
@property
def last_one_time_order(self):
return self.one_time_orders.last()
def create_order(self, when_to_start=None, recurring_period=None):
billing_address = BillingAddress.get_address_for(self.owner)
if not billing_address:
raise ValidationError("Cannot order without a billing address")
if not when_to_start:
when_to_start = timezone.now()
if not recurring_period:
recurring_period = self.default_recurring_period
# Create one time order if we did not create one already
if self.one_time_price > 0 and not self.last_one_time_order:
one_time_order = Order.objects.create(owner=self.owner,
billing_address=billing_address,
starting_date=when_to_start,
price=self.one_time_price,
recurring_period=RecurringPeriod.objects.get(name="ONE_TIME"),
description=str(self))
self.orders.add(one_time_order)
else:
one_time_order = None
if recurring_period != RecurringPeriod.objects.get(name="ONE_TIME"):
if one_time_order:
recurring_order = Order.objects.create(owner=self.owner,
billing_address=billing_address,
starting_date=when_to_start,
price=self.recurring_price,
recurring_period=recurring_period,
depends_on=one_time_order,
description=str(self))
else:
recurring_order = Order.objects.create(owner=self.owner,
billing_address=billing_address,
starting_date=when_to_start,
price=self.recurring_price,
recurring_period=recurring_period,
description=str(self))
self.orders.add(recurring_order)
# FIXME: this could/should be part of Order (?)
def create_or_update_recurring_order(self, when_to_start=None, recurring_period=None):
if not self.recurring_price:
@ -618,10 +530,83 @@ class Product(models.Model):
super().save(*args, **kwargs)
###
# Pricing
######
import logging
from django.db import models
logger = logging.getLogger(__name__)
class PricingPlan(models.Model):
name = models.CharField(max_length=255, unique=True)
vat_inclusive = models.BooleanField(default=True)
vat_percentage = models.DecimalField(
max_digits=7, decimal_places=5, blank=True, default=0
)
set_up_fees = models.DecimalField(
max_digits=7, decimal_places=2, default=0
)
cores_unit_price = models.DecimalField(
max_digits=7, decimal_places=2, default=0
)
ram_unit_price = models.DecimalField(
max_digits=7, decimal_places=2, default=0
)
storage_unit_price = models.DecimalField(
max_digits=7, decimal_places=2, default=0
)
discount_name = models.CharField(max_length=255, null=True, blank=True)
discount_amount = models.DecimalField(
max_digits=6, decimal_places=2, default=0
)
stripe_coupon_id = models.CharField(max_length=255, null=True, blank=True)
def __str__(self):
display_str = self.name + ' => ' + ' - '.join([
'{} Setup'.format(self.set_up_fees.normalize()),
'{}/Core'.format(self.cores_unit_price.normalize()),
'{}/GB RAM'.format(self.ram_unit_price.normalize()),
'{}/GB SSD'.format(self.storage_unit_price.normalize()),
'{}% VAT'.format(self.vat_percentage.normalize())
if not self.vat_inclusive else 'VAT-Incl',
])
if self.discount_amount:
display_str = ' - '.join([
display_str,
'{} {}'.format(
self.discount_amount,
self.discount_name if self.discount_name else 'Discount'
)
])
return display_str
@classmethod
def get_by_name(cls, name):
try:
pricing = PricingPlan.objects.get(name=name)
except Exception as e:
logger.error(
"Error getting VMPricing with name {name}. "
"Details: {details}. Attempting to return default"
"pricing.".format(name=name, details=str(e))
)
pricing = PricingPlan.get_default_pricing()
return pricing
@classmethod
def get_default_pricing(cls):
""" Returns the default pricing or None """
try:
default_pricing = PricingPlan.objects.get(name='default')
except Exception as e:
logger.error(str(e))
default_pricing = None
return default_pricing
###
# Orders.
class Order(models.Model):
"""
Order are assumed IMMUTABLE and used as SOURCE OF TRUST for generating
@ -650,6 +635,8 @@ class Order(models.Model):
billing_address = models.ForeignKey(BillingAddress,
on_delete=models.CASCADE)
customer = models.ForeignKey(StripeCustomer, on_delete=models.CASCADE, null=True)
description = models.TextField()
product = models.ForeignKey(Product, blank=False, null=False, on_delete=models.CASCADE)
@ -686,6 +673,7 @@ class Order(models.Model):
on_delete=models.CASCADE,
blank=True,
null=True)
pricing_plan = models.ForeignKey(PricingPlan, blank=False, null=True, on_delete=models.CASCADE)
should_be_billed = models.BooleanField(default=True)
@ -750,6 +738,17 @@ class Order(models.Model):
"""
return sum([ br.quantity for br in self.bill_records.all() ])
def cancel(self):
self.ending_date = timezone.now()
self.should_be_billed = False
self.save()
if self.instance_id:
last_bill_record = BillRecord.objects.filter(order=self).order_by('id').last()
schedule('matrixhosting.tasks.delete_instance',
self.instance_id,
schedule_type=Schedule.ONCE,
next_run=last_bill_record.ending_date or (timezone.now() + datetime.timedelta(hours=1)))
def count_used(self, when=None):
"""
@ -790,7 +789,7 @@ class Order(models.Model):
@property
def is_recurring(self):
return not self.recurring_period == RecurringPeriod.objects.get(name="ONE_TIME")
return self.recurring_price > 0
@property
def is_one_time(self):
@ -814,14 +813,12 @@ class Order(models.Model):
description=self.description,
product=self.product,
config=config,
pricing_plan=self.pricing_plan,
starting_date=starting_date,
currency=self.currency
)
(new_order.one_time_price, new_order.recurring_price, new_order.config) = new_order.calculate_prices_and_config()
new_order.recurring_price = new_order.calculate_recurring_price()
new_order.replaces = self
new_order.save()
@ -830,26 +827,28 @@ class Order(models.Model):
return new_order
def create_bill_record(self, bill):
br = None
# Note: check for != 0 not > 0, as we allow discounts to be expressed with < 0
if self.one_time_price != 0 and self.billrecord_set.count() == 0:
br = BillRecord.objects.create(bill=bill,
order=self,
starting_date=self.starting_date,
ending_date=self.starting_date,
is_recurring_record=False)
if self.recurring_price != 0:
br = BillRecord.objects.filter(bill=bill, order=self, is_recurring_record=True).first()
if br:
self.update_bill_record_for_recurring_order(br, bill)
records = BillRecord.objects.filter(order=self).all()
if not records:
if self.one_time_price:
br = BillRecord.objects.create(bill=bill,
order=self,
starting_date=self.starting_date,
ending_date=bill.ending_date,
is_recurring_record=False)
else:
br = self.create_new_bill_record_for_recurring_order(bill)
else:
br = self.create_new_bill_record_for_recurring_order(bill)
opened_recurring_record = BillRecord.objects.filter(bill=bill, order=self, is_recurring_record=True).first()
if opened_recurring_record:
br = opened_recurring_record
self.update_bill_record_for_recurring_order(br, bill)
else:
br = self.create_new_bill_record_for_recurring_order(bill)
return br
def update_bill_record_for_recurring_order(self,
@ -861,22 +860,21 @@ class Order(models.Model):
# If the order has an ending date set, we might need to adjust the bill_record
if self.ending_date:
if bill_record_for_this_bill.ending_date != self.ending_date:
bill_record_for_this_bill.ending_date = self.ending_date
if bill_record.ending_date != self.ending_date:
bill_record.ending_date = self.ending_date
else:
# recurring, not terminated, should go until at least end of bill
if bill_record_for_this_bill.ending_date < bill.ending_date:
bill_record_for_this_bill.ending_date = bill.ending_date
if bill_record.ending_date < bill.ending_date:
bill_record.ending_date = bill.ending_date
bill_record_for_this_bill.save()
bill_record.save()
def create_new_bill_record_for_recurring_order(self, bill):
"""
Create a new bill record
"""
last_bill_record = BillRecord.objects.filter(order=self, is_recurring_record=True).order_by('id').last()
last_bill_record = BillRecord.objects.filter(order=self).order_by('id').last()
starting_date=self.starting_date
@ -892,7 +890,6 @@ class Order(models.Model):
return
starting_date = start_after(last_bill_record.ending_date)
ending_date = self.get_ending_date_for_bill(bill)
return BillRecord.objects.create(bill=bill,
@ -901,47 +898,27 @@ class Order(models.Model):
ending_date=ending_date,
is_recurring_record=True)
def calculate_prices_and_config(self):
one_time_price = 0
recurring_price = 0
def calculate_recurring_price(self):
try:
config = json.loads(self.config)
recurring_price = 0
if 'cores' in config:
recurring_price += self.pricing_plan.cores_unit_price * int(config['cores'])
if 'memory' in config:
recurring_price += self.pricing_plan.ram_unit_price * int(config['memory'])
if 'storage' in config:
recurring_price += self.pricing_plan.storage_unit_price * int(config['storage'])
if self.config:
config = self.config
if 'features' not in self.config:
self.config['features'] = {}
else:
config = {
'features': {}
}
# FIXME: adjust prices to the selected recurring_period to the
if 'features' in self.product.config:
for feature in self.product.config['features']:
# Set min to 0 if not specified
min_val = self.product.config['features'][feature].get('min', 0)
# We might not even have 'features' cannot use .get() on it
try:
value = self.config['features'][feature]
except (KeyError, TypeError):
value = self.product.config['features'][feature]['min']
# Set max to current value if not specified
max_val = self.product.config['features'][feature].get('max', value)
if value < min_val or value > max_val:
raise ValidationError(f"Feature '{feature}' must be at least {min_val} and at maximum {max_val}. Value is: {value}")
one_time_price += self.product.config['features'][feature]['one_time_price_per_unit'] * value
recurring_price += self.product.config['features'][feature]['recurring_price_per_unit'] * value
config['features'][feature] = value
return (one_time_price, recurring_price, config)
vat_rate = VATRate.get_vat_rate(self.billing_address)
vat_validation_status = "verified" if self.billing_address.vat_number_validated_on and self.billing_address.vat_number_verified else False
subtotal, subtotal_after_discount, price_after_discount_with_vat, vat, vat_percent, discount = uncloud_pay.utils.apply_vat_discount(
recurring_price, self.pricing_plan,
vat_rate=vat_rate * 100, vat_validation_status = vat_validation_status
)
return price_after_discount_with_vat
except Exception as e:
logger.error("An error occurred while parsing the config obj", e)
return 0
def check_parameters(self):
if 'parameters' in self.product.config:
@ -955,7 +932,7 @@ class Order(models.Model):
# IMMUTABLE fields -- need to create new order to modify them
# However this is not enforced here...
if self._state.adding:
(self.one_time_price, self.recurring_price, self.config) = self.calculate_prices_and_config()
self.recurring_price = self.calculate_recurring_price()
if self.recurring_period_id is None:
self.recurring_period = self.product.default_recurring_period
@ -975,12 +952,7 @@ class Order(models.Model):
def __str__(self):
try:
conf = " ".join([ f"{key}:{val}" for key,val in self.config['features'].items() if val != 0 ])
except KeyError:
conf = ""
return f"Order {self.id}: {self.description} {conf}"
return f"Order {self.id}: {self.description}"
class Bill(models.Model):
"""
@ -1003,7 +975,7 @@ class Bill(models.Model):
# FIXME: editable=True -> is in the admin, but also editable in DRF
# Maybe filter fields in the serializer?
is_final = models.BooleanField(default=False)
is_closed = models.BooleanField(default=False)
class Meta:
constraints = [
@ -1017,8 +989,9 @@ class Bill(models.Model):
"""
Close/finish a bill
"""
self.is_final = True
self.is_closed = True
if not self.ending_date:
self.ending_date = timezone.now()
self.save()
@property
@ -1028,34 +1001,7 @@ class Bill(models.Model):
@property
def vat_rate(self):
"""
Handling VAT is a tricky business - thus we only implement the cases
that we clearly now and leave it open to fellow developers to implement
correct handling for other cases.
Case CH:
- If the customer is in .ch -> apply standard rate
- If the customer is in EU AND private -> apply country specific rate
- If the customer is in EU AND business -> do not apply VAT
- If the customer is outside EU and outside CH -> do not apply VAT
"""
provider = UncloudProvider.objects.get()
# Assume always VAT inside the country
if provider.country == self.billing_address.country:
vat_rate = VATRate.objects.get(country=provider.country,
when=self.ending_date)
elif self.billing_address.country in EU:
# FIXME: need to check for validated vat number
if self.billing_address.vat_number:
return 0
else:
return VATRate.objects.get(country=self.biling_address.country,
when=self.ending_date)
else: # non-EU, non-national
return 0
return VATRate.get_vat_rate(self.billing_address, when=self.ending_date)
@classmethod
@ -1075,9 +1021,10 @@ class Bill(models.Model):
"""
bills = []
for billing_address in BillingAddress.objects.filter(owner=owner):
bills.append(cls.create_next_bill_for_user_address(billing_address, ending_date))
bill = cls.create_next_bill_for_user_address(billing_address, ending_date)
if bill:
bills.append(bill)
return bills
@ -1089,15 +1036,18 @@ class Bill(models.Model):
owner = billing_address.owner
all_orders = Order.objects.filter(owner=owner,
billing_address=billing_address).order_by('id')
bill = cls.get_or_create_bill(billing_address, ending_date=ending_date)
for order in all_orders:
order.create_bill_record(bill)
return bill
all_orders = Order.objects.filter(Q(owner__id=owner.id), Q(should_be_billed=True),
Q(billing_address__id=billing_address.id)
).order_by('id')
if len(all_orders) > 0:
bill = cls.get_or_create_bill(billing_address, ending_date=ending_date)
for order in all_orders:
order.create_bill_record(bill)
return bill
else:
# This Customer Hasn't any active orders
return False
@classmethod
@ -1117,7 +1067,7 @@ class Bill(models.Model):
# Get date & bill from previous bill, if it exists
if last_bill:
if not last_bill.is_final:
if not last_bill.is_closed:
bill = last_bill
starting_date = last_bill.starting_date
ending_date = bill.ending_date
@ -1142,7 +1092,7 @@ class Bill(models.Model):
return bill
def __str__(self):
return f"{self.owner}-{self.id}"
@ -1167,9 +1117,11 @@ class BillRecord(models.Model):
if not self.is_recurring_record:
return 1
record_delta = self.ending_date - self.starting_date
return record_delta.total_seconds()/self.order.recurring_period.duration_seconds
record_delta = self.ending_date.date() - self.starting_date.date()
if self.order.recurring_period and self.order.recurring_period.duration_seconds > 0:
return int(record_delta.total_seconds() / self.order.recurring_period.duration_seconds)
else:
return 1
@property
def sum(self):

View file

@ -1,9 +1,5 @@
from django.utils import timezone
from django.db import transaction
from django.db.models import Q
from uncloud.selectors import filter_for_when
from uncloud.models import UncloudProvider
from .models import *
def get_payments_for_user(user):
@ -12,12 +8,11 @@ def get_payments_for_user(user):
return sum(payments)
def get_spendings_for_user(user):
orders = Order.objects.filter(owner=user)
bills = Bill.objects.filter(owner=user)
amount = 0
for order in orders:
amount += order.one_time_price
amount += order.recurring_price * order.count_used(when=timezone.now())
for bill in bills:
amount += bill.sum
return amount
@ -25,34 +20,12 @@ def get_spendings_for_user(user):
def get_balance_for_user(user):
return get_payments_for_user(user) - get_spendings_for_user(user)
@transaction.atomic
def has_enough_balance(user, due_amount):
balance = get_balance_for_user(user)
if balance >= due_amount:
return True
return False
def get_billing_address_for_user(user):
return BillingAddress.objects.get(owner=user, active=True)
def get_vat_rate(billing_address, when=None):
"""
Returns the VAT rate for business to customer.
B2B is always 0% with the exception of trading within the own country
"""
country = billing_address.country
# Need to have a provider country
uncloud_provider = filter_for_when(UncloudProvider.objects.all()).get()
vatrate = filter_for_when(VATRate.objects.filter(territory_codes=country), when).first()
# By default we charge VAT. This affects:
# - Same country sales (VAT applied)
# - B2C to EU (VAT applied)
rate = vatrate.rate
# Exception: if...
# - the billing_address is in EU,
# - the vat_number has been set
# - the vat_number has been verified
# Then we do not charge VAT
if uncloud_provider.country != country and billing_address.vat_number and billing_address.vat_number_verified:
rate = 0
return rate
return BillingAddress.objects.filter(owner=user, active=True).first()

View file

@ -86,8 +86,9 @@ class OrderSerializer(serializers.ModelSerializer):
class Meta:
model = Order
read_only_fields = ['replaced_by', 'depends_on']
fields = ['uuid', 'owner', 'description', 'creation_date', 'starting_date', 'ending_date',
'bill', 'recurring_period', 'recurring_price', 'one_time_price'] + read_only_fields
fields = ['owner', 'description', 'creation_date', 'starting_date', 'ending_date',
'recurring_period', 'recurring_price', 'one_time_price',
'config', 'pricing_plan', 'should_be_billed'] + read_only_fields
###
@ -114,13 +115,13 @@ class BillSerializer(serializers.ModelSerializer):
class Meta:
model = Bill
fields = ['uuid', 'reference', 'owner', 'amount', 'vat_amount', 'total',
fields = ['owner', 'sum', 'vat_rate',
'due_date', 'creation_date', 'starting_date', 'ending_date',
'records', 'final', 'billing_address']
'records', 'is_closed', 'billing_address']
# We do not want users to mutate the country / VAT number of an address, as it
# will change VAT on existing bills.
class UpdateBillingAddressSerializer(serializers.ModelSerializer):
class Meta:
model = BillingAddress
fields = ['uuid', 'street', 'city', 'postal_code']
fields = ['street', 'city', 'postal_code']

View file

@ -1,3 +1,5 @@
import datetime
from calendar import monthrange
from django.utils import timezone

View file

@ -9,6 +9,8 @@ from django.contrib.auth import get_user_model
from .models import StripeCustomer, StripeCreditCard
logger = logging.getLogger(__name__)
CURRENCY = 'chf'
stripe.api_key = settings.STRIPE_KEY
@ -77,9 +79,24 @@ def create_setup_intent(customer_id):
def get_setup_intent(setup_intent_id):
return stripe.SetupIntent.retrieve(setup_intent_id)
@handle_stripe_error
def get_payment_method(payment_method_id):
return stripe.PaymentMethod.retrieve(payment_method_id)
@handle_stripe_error
def get_card_from_payment(user, payment_method_id):
payment_method = stripe.PaymentMethod.retrieve(payment_method_id)
if payment_method:
if 'card' in payment_method:
sync_cards_for_user(user)
return payment_method['card']
return False
@handle_stripe_error
def attach_payment_method(payment_method_id, customer_id):
return stripe.PaymentMethod.attach(payment_method_id, customer=customer_id)
@handle_stripe_error
def create_customer(name, email):
return stripe.Customer.create(name=name, email=email)
@ -142,7 +159,7 @@ def sync_cards_for_user(user):
)
@handle_stripe_error
def charge_customer(user, amount, currency='CHF'):
def charge_customer(user, amount, currency='CHF', card=False):
# Amount is in CHF but stripes requires smallest possible unit.
# https://stripe.com/docs/api/payment_intents/create#create_payment_intent-amount
# FIXME: might need to be adjusted for other currencies
@ -153,14 +170,14 @@ def charge_customer(user, amount, currency='CHF'):
return Exception("Programming error: unsupported currency")
try:
card = StripeCreditCard.objects.get(owner=user,
card = card or StripeCreditCard.objects.get(owner=user,
active=True)
except StripeCreditCard.DoesNotExist:
raise ValidationError("No active credit card - cannot create payment")
customer_id = get_customer_id_for(user)
return stripe.PaymentIntent.create(
amount=adjusted_amount,
currency=currency,
@ -169,3 +186,64 @@ def charge_customer(user, amount, currency='CHF'):
off_session=True,
confirm=True,
)
@handle_stripe_error
def get_payment_intent(user, amount, currency='CHF', card=False):
# Amount is in CHF but stripes requires smallest possible unit.
# https://stripe.com/docs/api/payment_intents/create#create_payment_intent-amount
# FIXME: might need to be adjusted for other currencies
if currency == 'CHF':
adjusted_amount = int(amount * 100)
else:
return Exception("Programming error: unsupported currency")
try:
card = card or StripeCreditCard.objects.get(owner=user,
active=True)
except StripeCreditCard.DoesNotExist:
raise ValidationError("No active credit card - cannot create payment")
customer_id = get_customer_id_for(user)
return stripe.PaymentIntent.create(
amount=adjusted_amount,
currency=currency,
customer=customer_id,
payment_method=card.card_id,
setup_future_usage='off_session',
confirm=False,
)
@handle_stripe_error
def get_or_create_tax_id_for_user(stripe_customer_id, vat_number,
type="eu_vat", country=""):
def compare_vat_numbers(vat1, vat2):
_vat1 = vat1.replace(" ", "").replace(".", "").replace("-","")
_vat2 = vat2.replace(" ", "").replace(".", "").replace("-","")
return True if _vat1 == _vat2 else False
tax_ids_list = stripe.Customer.list_tax_ids(
stripe_customer_id,
limit=100,
)
for tax_id_obj in tax_ids_list.data:
if compare_vat_numbers(tax_id_obj.value, vat_number):
return tax_id_obj
else:
logger.debug(
"{val1} is not equal to {val2} or {con1} not same as "
"{con2}".format(val1=tax_id_obj.value, val2=vat_number,
con1=tax_id_obj.country.lower(),
con2=country.lower().strip()))
logger.debug(
"tax id obj does not exist for {val}. Creating a new one".format(
val=vat_number
))
tax_id_obj = stripe.Customer.create_tax_id(
stripe_customer_id,
type=type,
value=vat_number,
)
return tax_id_obj

View file

@ -1,11 +0,0 @@
from celery import shared_task
from .models import *
import uuid
from uncloud.models import UncloudTask
@shared_task(bind=True)
def check_balance(self):
UncloudTask.objects.create(task_id=self.request.id)
print("for each user res is 50")
return 50

View file

@ -1,12 +1,11 @@
{% extends 'uncloud/base.html' %}
{% block bootstrap5_extra_head %}
<script src="https://code.jquery.com/jquery-3.6.0.min.js" integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4=" crossorigin="anonymous"></script>
<script src="https://js.stripe.com/v3/"></script>
{% endblock %}
{% block bootstrap5_content %}
<div class="container">
{% csrf_token %}
<div id="content">
<h1>Register Credit Card with Stripe</h1>
<p>
@ -18,10 +17,15 @@
<span id="message"></span>
<div id="card-element"></div>
<div id="card-errors" role="alert"></div>
<button type='button' id="card-button">Save</button>
<input id="cardholder-name" type="text">
<!-- placeholder for Elements -->
<form id="setup-form" data-secret="{{ client_secret }}">
<div id="card-element"></div>
<div id="card-errors" role="alert"></div>
<button id="card-button">
Save Card
</button>
</form>
<div id="ungleichmessage">The card will be registered with stripe.</div>
<div id="goback" style="display: none;">
@ -32,44 +36,43 @@
<!-- Enable Stripe from UI elements - standard code -->
<script>
var cardholderName = document.getElementById('cardholder-name');
var setupForm = document.getElementById('setup-form');
var clientSecret = setupForm.dataset.secret;
var stripe = Stripe('{{ stripe_pk }}');
var elements = stripe.elements();
var cardElement = elements.create('card');
cardElement.mount('#card-element');
var cardButton = document.getElementById('card-button');
var messageContainer = document.getElementById('message');
var backmessage = document.getElementById('goback');
var clientSecret = '{{ client_secret }}';
cardButton.addEventListener('click', function(ev) {
document.getElementById("ungleichmessage").innerHTML
= "Registering card with Stripe, please wait ...";
var elements = stripe.elements();
var cardElement = elements.create('card');
cardElement.mount('#card-element');
stripe.confirmCardSetup(
clientSecret,
{
payment_method: {
card: cardElement,
billing_details: { name: "{{username}}", },
},
}
).then(function(result) {
if (result.error) {
setupForm.addEventListener('submit', function(ev) {
ev.preventDefault();
stripe.confirmCardSetup(
clientSecret,
{
payment_method: {
card: cardElement,
billing_details: {
name: cardholderName.value,
},
},
}
).then(function(result) {
if (result.error) {
var message = document.createTextNode('Error:' + result.error.message);
messageContainer.appendChild(message);
} else {
// Return to API on success.
document.getElementById("ungleichmessage").innerHTML
document.getElementById("ungleichmessage").innerHTML
= "Registered credit card with Stripe."
backmessage.style.display = "block";
// Return to API on success.
}
});
});
});
</script>
{% endblock %}

View file

@ -5,52 +5,28 @@ from django.utils import timezone
from .models import *
from uncloud_service.models import GenericServiceProduct
from uncloud.models import UncloudProvider
from uncloud.models import UncloudProvider, UncloudNetwork
import json
chocolate_product_config = {
'features': {
'gramm':
{ 'min': 100,
'max': 5000,
'one_time_price_per_unit': 0.2,
'recurring_price_per_unit': 0
},
},
}
chocolate_order_config = {
'features': {
'gramm': 500,
}
}
chocolate_one_time_price = chocolate_order_config['features']['gramm'] * chocolate_product_config['features']['gramm']['one_time_price_per_unit']
vm_product_config = {
'features': {
'cores':
{ 'min': 1,
'max': 48,
'one_time_price_per_unit': 0,
'recurring_price_per_unit': 4
'max': 48
},
'ram_gb':
{ 'min': 1,
'max': 256,
'one_time_price_per_unit': 0,
'recurring_price_per_unit': 4
'max': 256
},
},
}
vm_order_config = {
'features': {
'cores': 2,
'ram_gb': 2
}
}
vm_order_config = json.dumps({
'cores': 1,
'memory': 2,
'storage': 100
})
vm_order_downgrade_config = {
'features': {
@ -87,12 +63,11 @@ class ProductTestCase(TestCase):
RecurringPeriod.populate_db_defaults()
self.default_recurring_period = RecurringPeriod.objects.get(name="Per 30 days")
def test_create_product(self):
"""
Create a sample product
"""
p = Product.objects.create(name="Testproduct",
description="Only for testing",
config=vm_product_config)
@ -107,6 +82,8 @@ class OrderTestCase(TestCase):
"""
def setUp(self):
self.pricing_plan = PricingPlan.objects.create(name="PricingSample", set_up_fees=35, cores_unit_price=3,
ram_unit_price=4, storage_unit_price=0.02)
self.user = get_user_model().objects.create(
username='random_user',
email='jane.random@domain.tld')
@ -135,23 +112,35 @@ class OrderTestCase(TestCase):
Order a products with a recurringperiod that is not added to the product
"""
order_config = json.dumps({
'cores': 1,
'memory':2,
'storage': 100
})
o = Order.objects.create(owner=self.user,
billing_address=self.ba,
pricing_plan = self.pricing_plan,
product=self.product,
config=vm_order_config)
config=order_config)
def test_order_product(self):
"""
Order a product, ensure the order has correct price setup
"""
order_config = json.dumps({
'cores': 1,
'memory':2,
'storage': 100
})
o = Order.objects.create(owner=self.user,
billing_address=self.ba,
product=self.product)
pricing_plan = self.pricing_plan,
product=self.product,
config=order_config)
self.assertEqual(o.one_time_price, 0)
self.assertEqual(o.recurring_price, 16)
self.assertEqual(o.recurring_price, 13.0)
def test_change_order(self):
"""
@ -159,14 +148,19 @@ class OrderTestCase(TestCase):
- a new order is created
- the price is correct in the new order
"""
order_config = json.dumps({
'cores': 2,
'memory':4,
'storage': 200
})
order1 = Order.objects.create(owner=self.user,
billing_address=self.ba,
pricing_plan = self.pricing_plan,
product=self.product,
config=vm_order_config)
config=order_config)
self.assertEqual(order1.one_time_price, 0)
self.assertEqual(order1.recurring_price, 16)
self.assertEqual(order1.recurring_price, 26.0)
class ModifyOrderTestCase(TestCase):
@ -181,7 +175,18 @@ class ModifyOrderTestCase(TestCase):
self.user = get_user_model().objects.create(
username='random_user',
email='jane.random@domain.tld')
self.pricing_plan = PricingPlan.objects.create(name="PricingSample", set_up_fees=35, cores_unit_price=3,
ram_unit_price=4, storage_unit_price=0.02)
self.order1_config = json.dumps({
'cores': 2,
'memory':4,
'storage': 200
})
self.order2_config = json.dumps({
'cores': 1,
'memory':2,
'storage': 100
})
self.ba = BillingAddress.objects.create(
owner=self.user,
organization = 'Test org',
@ -226,10 +231,11 @@ class ModifyOrderTestCase(TestCase):
order1 = Order.objects.create(owner=self.user,
billing_address=BillingAddress.get_address_for(self.user),
product=self.product,
config=vm_order_config,
config=self.order1_config,
pricing_plan=self.pricing_plan,
starting_date=starting_date)
order1.update_order(vm_order_downgrade_config, starting_date=change1_date)
order1.update_order(self.order2_config, starting_date=change1_date)
bills = Bill.create_next_bills_for_user(user, ending_date=bill_ending_date)
@ -270,24 +276,26 @@ class ModifyOrderTestCase(TestCase):
first_order_should_end_at = starting_date + datetime.timedelta(days=30)
change1_date = start_after(starting_date + datetime.timedelta(days=15))
bill_ending_date = change1_date + datetime.timedelta(days=1)
order1 = Order.objects.create(owner=self.user,
billing_address=BillingAddress.get_address_for(self.user),
product=self.product,
config=vm_order_config,
pricing_plan=self.pricing_plan,
config=self.order1_config,
starting_date=starting_date)
order1.update_order(vm_order_downgrade_config, starting_date=change1_date)
bills = Bill.create_next_bills_for_user(user, ending_date=bill_ending_date)
bill = bills[0]
bill_records = BillRecord.objects.filter(bill=bill)
self.assertEqual(len(bill_records), 2)
self.assertEqual(len(bill_records), 1)
self.assertEqual(bill_records[0].starting_date, starting_date)
self.assertEqual(bill_records[0].order.ending_date, first_order_should_end_at)
order1.update_order(self.order2_config, starting_date=change1_date)
bills = Bill.create_next_bills_for_user(user, ending_date=bill_ending_date)
bill_records = BillRecord.objects.filter(bill=bill)
self.assertEqual(len(bill_records), 2)
self.assertEqual(bill_records[0].order.ending_date.date(), change1_date.date())
class BillTestCase(TestCase):
@ -298,6 +306,9 @@ class BillTestCase(TestCase):
def setUp(self):
RecurringPeriod.populate_db_defaults()
self.pricing_plan = PricingPlan.objects.create(name="PricingSample", set_up_fees=35, cores_unit_price=3,
ram_unit_price=4, storage_unit_price=0.02)
self.user_without_address = get_user_model().objects.create(
username='no_home_person',
email='far.away@domain.tld')
@ -331,12 +342,12 @@ class BillTestCase(TestCase):
'starting_date': timezone.make_aware(datetime.datetime(2020,3,3)),
'ending_date': timezone.make_aware(datetime.datetime(2020,4,17)),
'price': 15,
'description': 'One chocolate bar'
'description': ''
}
self.chocolate = Product.objects.create(name="Swiss Chocolate",
self.product = Product.objects.create(name="Product Sample",
description="Not only for testing, but for joy",
config=chocolate_product_config)
config=vm_product_config)
self.vm = Product.objects.create(name="Super Fast VM",
@ -349,7 +360,7 @@ class BillTestCase(TestCase):
self.onetime_recurring_period = RecurringPeriod.objects.get(name="Onetime")
self.chocolate.recurring_periods.add(self.onetime_recurring_period,
self.product.recurring_periods.add(self.onetime_recurring_period,
through_defaults= { 'is_default': True })
self.vm.recurring_periods.add(self.default_recurring_period,
@ -364,15 +375,16 @@ class BillTestCase(TestCase):
]
def order_chocolate(self):
def order_product(self):
return Order.objects.create(
owner=self.user,
recurring_period=RecurringPeriod.objects.get(name="Onetime"),
product=self.chocolate,
product=self.product,
billing_address=BillingAddress.get_address_for(self.user),
starting_date=self.order_meta[1]['starting_date'],
ending_date=self.order_meta[1]['ending_date'],
config=chocolate_order_config)
pricing_plan=self.pricing_plan,
config=vm_order_config)
def order_vm(self, owner=None):
@ -383,27 +395,52 @@ class BillTestCase(TestCase):
owner=owner,
product=self.vm,
config=vm_order_config,
pricing_plan=self.pricing_plan,
billing_address=BillingAddress.get_address_for(self.recurring_user),
starting_date=timezone.make_aware(datetime.datetime(2020,3,3)),
)
return Order.objects.create(
def test_bill_one_time_with_recurring(self):
"""
Validate that if the order contains one_time_price and recurring_pricing
One Bill records should be created
"""
order = Order.objects.create(
owner=self.user,
recurring_period=RecurringPeriod.objects.get(name="Onetime"),
product=self.chocolate,
product=self.vm,
config=vm_order_config,
pricing_plan=self.pricing_plan,
one_time_price = 35,
billing_address=BillingAddress.get_address_for(self.user),
starting_date=self.order_meta[1]['starting_date'],
ending_date=self.order_meta[1]['ending_date'],
config=chocolate_order_config)
starting_date=timezone.make_aware(datetime.datetime(2020,3,3)),
)
bill = Bill.create_next_bill_for_user_address(self.user_addr)
self.assertEqual(order.billrecord_set.count(), 1)
record = order.billrecord_set.first()
self.assertEqual(record.is_recurring_record, False)
self.assertEqual(record.price, 35)
self.assertEqual(record.quantity, 1)
self.assertEqual(record.sum, 35)
#close the bill as it has been paid
bill.close()
bill2 = Bill.create_next_bill_for_user_address(self.user_addr)
self.assertNotEqual(bill.id, bill2.id)
self.assertEqual(order.billrecord_set.count(), 2)
record = BillRecord.objects.filter(bill=bill2, order=order).first()
self.assertEqual(record.is_recurring_record, True)
self.assertEqual(record.price, 13)
self.assertEqual(record.quantity, 1)
self.assertEqual(record.sum, 13)
def test_bill_one_time_one_bill_record(self):
"""
Ensure there is only 1 bill record per order
"""
order = self.order_chocolate()
order = self.order_product()
bill = Bill.create_next_bill_for_user_address(self.user_addr)
@ -414,9 +451,14 @@ class BillTestCase(TestCase):
Check the bill sum for a single one time order
"""
order = self.order_chocolate()
order = self.order_product()
self.assertEqual(order.recurring_price, 13.0)
bill = Bill.create_next_bill_for_user_address(self.user_addr)
self.assertEqual(bill.sum, chocolate_one_time_price)
self.assertEqual(order.billrecord_set.count(), 1)
record = order.billrecord_set.first()
self.assertEqual(record.price, 13)
self.assertEqual(record.quantity, 1)
self.assertEqual(bill.sum, 13)
def test_bill_creates_record_for_recurring_order(self):
@ -461,7 +503,7 @@ class BillingAddressTestCase(TestCase):
Raise an error, when there is no address
"""
self.assertRaises(uncloud_pay.models.BillingAddress.DoesNotExist,
self.assertRaises(BillingAddress.DoesNotExist,
BillingAddress.get_address_for,
self.user)
@ -478,7 +520,8 @@ class VATRatesTestCase(TestCase):
city="unknown",
postal_code="unknown",
active=True)
UncloudNetwork.populate_db_defaults()
UncloudProvider.populate_db_defaults()

155
uncloud_pay/utils.py Normal file
View file

@ -0,0 +1,155 @@
import logging
import decimal
import datetime
from . import stripe as uncloud_stripe
import stripe
from .models import PricingPlan, BillingAddress
logger = logging.getLogger(__name__)
eu_countries = ['at', 'be', 'bg', 'ch', 'cy', 'cz', 'hr', 'dk',
'ee', 'fi', 'fr', 'mc', 'de', 'gr', 'hu', 'ie', 'it',
'lv', 'lu', 'mt', 'nl', 'po', 'pt', 'ro','sk', 'si', 'es',
'se', 'gb']
def validate_vat_number(stripe_customer_id, billing_address_id):
try:
billing_address = BillingAddress.objects.get(id=billing_address_id)
except BillingAddress.DoesNotExist as dne:
billing_address = None
except BillingAddress.MultipleObjectsReturned as mor:
billing_address = BillingAddress.objects.filter(id=billing_address_id).order_by('-id').first()
if billing_address is not None:
logger.debug("BillingAddress found: %s %s" % (
billing_address_id, str(billing_address)))
if billing_address.country.lower().strip() not in eu_countries:
return {
"validated_on": "",
"status": "not_needed"
}
if billing_address.vat_number_validated_on and billing_address.vat_number_verified:
return {
"validated_on": billing_address.vat_number_validated_on,
"status": "verified"
}
else:
if billing_address.stripe_tax_id:
logger.debug("We have a tax id %s" % billing_address.stripe_tax_id)
tax_id_obj = stripe.Customer.retrieve_tax_id(
stripe_customer_id,
billing_address.stripe_tax_id,
)
if tax_id_obj.verification.status == "verified":
logger.debug("Latest status on Stripe=%s. Updating" %
tax_id_obj.verification.status)
# update billing address
billing_address.vat_number_validated_on = datetime.datetime.now()
billing_address.vat_number_verified = True
billing_address.save()
return {
"status": "verified",
"validated_on": billing_address.vat_number_validated_on
}
else:
billing_address.vat_number_validated_on = datetime.datetime.now()
billing_address.vat_number_verified = False
billing_address.save()
else:
logger.debug("Creating a tax id")
tax_id_obj = create_tax_id(
stripe_customer_id, billing_address_id,
"ch_vat" if billing_address.country.lower() == "ch" else "eu_vat",
)
else:
logger.debug("invalid billing address")
return {
"status": "invalid billing address",
"validated_on": ""
}
return {
"status": tax_id_obj.verification.status if 'verification' in tax_id_obj else "unknown",
"validated_on": datetime.datetime.now() if tax_id_obj.verification.status == "verified" else ""
}
def create_tax_id(stripe_customer_id, billing_address_id, type):
try:
billing_address = BillingAddress.objects.get(id=billing_address_id)
except BillingAddress.DoesNotExist as dne:
billing_address = None
logger.debug("BillingAddress does not exist for %s" % billing_address_id)
except BillingAddress.MultipleObjectsReturned as mor:
logger.debug("Multiple BillingAddress exist for %s" % billing_address_id)
billing_address = BillingAddress.objects.filter(billing_address_id).order_by('-id').first()
tax_id_obj = None
if billing_address:
try:
tax_id_obj = uncloud_stripe.get_or_create_tax_id_for_user(
stripe_customer_id,
vat_number=billing_address.vat_number,
type=type,
country=billing_address.country
)
billing_address.stripe_tax_id = tax_id_obj.id
billing_address.vat_number_verified = True if tax_id_obj.verification.status == "verified" else False
billing_address.save()
return tax_id_obj
except Exception as e:
logger.debug("Received none in tax_id_obj")
return {
'verification': None,
'error': str(e)
}
def apply_vat_discount(subtotal, pricing_plan, vat_rate=False, vat_validation_status=False):
vat_percent = vat_rate or pricing_plan.vat_percentage
if pricing_plan.vat_inclusive or (vat_validation_status and vat_validation_status in ["verified", "not_needed"]):
vat_percent = decimal.Decimal(0)
vat = decimal.Decimal(0)
else:
vat = subtotal * decimal.Decimal(vat_rate) * decimal.Decimal(0.01)
discount_amount = 0
discount_amount_with_vat = 0
if pricing_plan.discount_amount:
discount_amount = round(float(pricing_plan.discount_amount), 2)
discount_amount_with_vat = decimal.Decimal(discount_amount) * (1 + decimal.Decimal(vat_rate) * decimal.Decimal(0.01))
discount_amount_with_vat = discount_amount_with_vat
subtotal = round(float(subtotal), 2)
vat_percent = round(float(vat_percent), 2)
discount = {
'name': pricing_plan.discount_name,
'amount': discount_amount,
'amount_with_vat': round(float(discount_amount_with_vat), 2)
}
subtotal_after_discount = subtotal - discount["amount"]
price_after_discount_with_vat = round((subtotal - discount['amount']) * (1 + vat_percent * 0.01), 2)
return (subtotal, round(float(subtotal_after_discount), 2), price_after_discount_with_vat,
round(float(vat), 2), vat_percent, discount)
def get_order_total_with_vat(cores, memory, storage,
pricing_name='default', vat_rate=False, vat_validation_status=False):
try:
pricing = PricingPlan.objects.get(name=pricing_name)
except Exception as ex:
logger.error(
"Error getting PricingPlan object for {pricing_name}."
"Details: {details}".format(
pricing_name=pricing_name, details=str(ex)
)
)
return None
subtotal = (
pricing.set_up_fees +
(decimal.Decimal(cores) * pricing.cores_unit_price) +
(decimal.Decimal(memory) * pricing.ram_unit_price) +
(decimal.Decimal(storage) * (pricing.storage_unit_price))
)
return apply_vat_discount(subtotal, pricing, vat_rate, vat_validation_status)

View file

@ -1,7 +1,5 @@
from django.contrib.auth.mixins import LoginRequiredMixin
from django.views.generic.base import TemplateView
from django.shortcuts import render
from django.db import transaction
from django.contrib.auth import get_user_model
@ -29,27 +27,31 @@ from .selectors import *
from datetime import datetime
from vat_validator import sanitize_vat
import uncloud_pay.stripe as uncloud_stripe
from django.contrib.auth.decorators import login_required
from django.utils.decorators import method_decorator
from django.http import JsonResponse
import stripe
logger = logging.getLogger(__name__)
###
# 2020-12 checked code
class RegisterCard(LoginRequiredMixin, TemplateView):
login_url = '/login/'
class RegisterCard(TemplateView):
template_name = "uncloud_pay/register_stripe.html"
@method_decorator(login_required)
def dispatch(self, *args, **kwargs):
return super().dispatch(*args, **kwargs)
def get_context_data(self, **kwargs):
customer_id = uncloud_stripe.get_customer_id_for(self.request.user)
setup_intent = uncloud_stripe.create_setup_intent(customer_id)
context = super().get_context_data(**kwargs)
context['client_secret'] = setup_intent.client_secret
context['username'] = self.request.user
context['username'] = self.request.user.username
context['stripe_pk'] = uncloud_stripe.public_api_key
return context
@ -70,7 +72,6 @@ class CreditCardViewSet(mixins.RetrieveModelMixin,
def get_queryset(self):
return StripeCreditCard.objects.filter(owner=self.request.user)
class PaymentViewSet(viewsets.ModelViewSet):
serializer_class = PaymentSerializer
permission_classes = [permissions.IsAuthenticated]
@ -89,24 +90,13 @@ class BalanceViewSet(viewsets.ViewSet):
return Response(serializer.data)
###
# Payments and Payment Methods.
class OrderViewSet(viewsets.ReadOnlyModelViewSet):
serializer_class = OrderSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
return Order.objects.filter(owner=self.request.user)
class ListCards(LoginRequiredMixin, TemplateView):
login_url = '/login/'
class ListCards(TemplateView):
template_name = "uncloud_pay/list_stripe.html"
@method_decorator(login_required)
def dispatch(self, *args, **kwargs):
return super().dispatch(*args, **kwargs)
def get_context_data(self, **kwargs):
customer_id = uncloud_stripe.get_customer_id_for(self.request.user)
cards = uncloud_stripe.get_customer_cards(customer_id)
@ -117,140 +107,6 @@ class ListCards(LoginRequiredMixin, TemplateView):
return context
class PaymentMethodViewSet(viewsets.ModelViewSet):
permission_classes = [permissions.IsAuthenticated]
def get_serializer_class(self):
if self.action == 'create':
return CreatePaymentMethodSerializer
elif self.action == 'update':
return UpdatePaymentMethodSerializer
elif self.action == 'charge':
return ChargePaymentMethodSerializer
else:
return PaymentMethodSerializer
def get_queryset(self):
return PaymentMethod.objects.filter(owner=self.request.user)
# XXX: Handling of errors is far from great down there.
@transaction.atomic
def create(self, request):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
# Set newly created method as primary if no other method is.
if PaymentMethod.get_primary_for(request.user) == None:
serializer.validated_data['primary'] = True
if serializer.validated_data['source'] == "stripe":
# Retrieve Stripe customer ID for user.
customer_id = uncloud_stripe.get_customer_id_for(request.user)
if customer_id == None:
return Response(
{'error': 'Could not resolve customer stripe ID.'},
status=status.HTTP_500_INTERNAL_SERVER_ERROR)
try:
setup_intent = uncloud_stripe.create_setup_intent(customer_id)
except Exception as e:
return Response({'error': str(e)},
status=status.HTTP_500_INTERNAL_SERVER_ERROR)
payment_method = PaymentMethod.objects.create(
owner=request.user,
stripe_setup_intent_id=setup_intent.id,
**serializer.validated_data)
# TODO: find a way to use reverse properly:
# https://www.django-rest-framework.org/api-guide/reverse/
path = "payment-method/{}/register-stripe-cc".format(
payment_method.uuid)
stripe_registration_url = reverse('api-root', request=request) + path
return Response({'please_visit': stripe_registration_url})
else:
serializer.save(owner=request.user, **serializer.validated_data)
return Response(serializer.data)
@action(detail=True, methods=['post'])
def charge(self, request, pk=None):
payment_method = self.get_object()
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
amount = serializer.validated_data['amount']
try:
payment = payment_method.charge(amount)
output_serializer = PaymentSerializer(payment)
return Response(output_serializer.data)
except Exception as e:
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
@action(detail=True, methods=['get'], url_path='register-stripe-cc', renderer_classes=[TemplateHTMLRenderer])
def register_stripe_cc(self, request, pk=None):
payment_method = self.get_object()
if payment_method.source != 'stripe':
return Response(
{'error': 'This is not a Stripe-based payment method.'},
template_name='error.html.j2')
if payment_method.active:
return Response(
{'error': 'This payment method is already active'},
template_name='error.html.j2')
try:
setup_intent = uncloud_stripe.get_setup_intent(
payment_method.stripe_setup_intent_id)
except Exception as e:
return Response(
{'error': str(e)},
template_name='error.html.j2')
# TODO: find a way to use reverse properly:
# https://www.django-rest-framework.org/api-guide/reverse/
callback_path= "payment-method/{}/activate-stripe-cc/".format(
payment_method.id)
callback = reverse('api-root', request=request) + callback_path
# Render stripe card registration form.
template_args = {
'client_secret': setup_intent.client_secret,
'stripe_pk': uncloud_stripe.public_api_key,
'callback': callback
}
return Response(template_args, template_name='stripe-payment.html.j2')
@action(detail=True, methods=['post'], url_path='activate-stripe-cc')
def activate_stripe_cc(self, request, pk=None):
payment_method = self.get_object()
try:
setup_intent = uncloud_stripe.get_setup_intent(
payment_method.stripe_setup_intent_id)
except Exception as e:
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
# Card had been registered, fetching payment method.
print(setup_intent)
if setup_intent.payment_method:
payment_method.stripe_payment_method_id = setup_intent.payment_method
payment_method.save()
return Response({
'uuid': payment_method.uuid,
'activated': payment_method.active})
else:
error = 'Could not fetch payment method from stripe. Please try again.'
return Response({'error': error})
@action(detail=True, methods=['post'], url_path='set-as-primary')
def set_as_primary(self, request, pk=None):
payment_method = self.get_object()
payment_method.set_as_primary_for(request.user)
serializer = self.get_serializer(payment_method)
return Response(serializer.data)
###
# Bills and Orders.
@ -314,7 +170,7 @@ class BillingAddressViewSet(mixins.CreateModelMixin,
return BillingAddressSerializer
def get_queryset(self):
return self.request.user.billingaddress_set.all()
return self.request.user.billing_addresses.all()
def create(self, request):
serializer = self.get_serializer(data=request.data)