- 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:
parent
e205d8d07c
commit
b7aa1c6971
81 changed files with 5081 additions and 812 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.")
|
||||
|
|
|
|||
|
|
@ -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!')
|
||||
|
|
|
|||
23
uncloud_pay/migrations/0012_auto_20210630_0742.py
Normal file
23
uncloud_pay/migrations/0012_auto_20210630_0742.py
Normal 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),
|
||||
),
|
||||
]
|
||||
21
uncloud_pay/migrations/0013_alter_billingaddress_owner.py
Normal file
21
uncloud_pay/migrations/0013_alter_billingaddress_owner.py
Normal 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),
|
||||
),
|
||||
]
|
||||
23
uncloud_pay/migrations/0014_auto_20210703_1747.py
Normal file
23
uncloud_pay/migrations/0014_auto_20210703_1747.py
Normal 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),
|
||||
),
|
||||
]
|
||||
34
uncloud_pay/migrations/0015_auto_20210705_0849.py
Normal file
34
uncloud_pay/migrations/0015_auto_20210705_0849.py
Normal 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),
|
||||
),
|
||||
]
|
||||
29
uncloud_pay/migrations/0016_pricingplan.py
Normal file
29
uncloud_pay/migrations/0016_pricingplan.py
Normal 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)),
|
||||
],
|
||||
),
|
||||
]
|
||||
23
uncloud_pay/migrations/0017_auto_20210706_1728.py
Normal file
23
uncloud_pay/migrations/0017_auto_20210706_1728.py
Normal 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',
|
||||
),
|
||||
]
|
||||
30
uncloud_pay/migrations/0018_payment.py
Normal file
30
uncloud_pay/migrations/0018_payment.py
Normal 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)),
|
||||
],
|
||||
),
|
||||
]
|
||||
19
uncloud_pay/migrations/0019_order_pricing_plan.py
Normal file
19
uncloud_pay/migrations/0019_order_pricing_plan.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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',
|
||||
),
|
||||
]
|
||||
21
uncloud_pay/migrations/0021_auto_20210709_0914.py
Normal file
21
uncloud_pay/migrations/0021_auto_20210709_0914.py
Normal 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',
|
||||
),
|
||||
]
|
||||
17
uncloud_pay/migrations/0022_remove_order_status.py
Normal file
17
uncloud_pay/migrations/0022_remove_order_status.py
Normal 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',
|
||||
),
|
||||
]
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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']
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import datetime
|
||||
from calendar import monthrange
|
||||
from django.utils import timezone
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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
155
uncloud_pay/utils.py
Normal 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)
|
||||
|
||||
|
||||
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue