forked from uncloud/uncloud
- 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 5079 additions and 810 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -22,6 +22,6 @@ uncloud/version.py
|
|||
build/
|
||||
venv/
|
||||
dist/
|
||||
|
||||
.history/
|
||||
*.iso
|
||||
*.sqlite3
|
||||
|
|
10
README.md
10
README.md
|
@ -9,13 +9,15 @@ Cloud management platform, the ungleich way.
|
|||
## Useful commands
|
||||
|
||||
* `./manage.py import-vat-rates path/to/csv`
|
||||
* `./manage.py make-admin username`
|
||||
* `./manage.py createsuperuser`
|
||||
|
||||
## Development setup
|
||||
|
||||
Install system dependencies:
|
||||
|
||||
* On Fedora, you will need the following packages: `python3-virtualenv python3-devel openldap-devel gcc chromium`
|
||||
* sudo apt-get install libpq-dev python-dev libxml2-dev libxslt1-dev libldap2-dev libsasl2-dev libffi-dev
|
||||
|
||||
|
||||
NOTE: you will need to configure a LDAP server and credentials for authentication. See `uncloud/settings.py`.
|
||||
|
||||
|
@ -53,6 +55,12 @@ Django version 3.0.6, using settings 'uncloud.settings'
|
|||
Starting development server at http://127.0.0.1:8000/
|
||||
Quit the server with CONTROL-C.
|
||||
```
|
||||
### Run Background Job Queue
|
||||
We use Django Q to handle the asynchronous code and Background Cron jobs
|
||||
To start the workers make sure first that Redis or the Django Q broker is working and you can edit it's settings in the settings file.
|
||||
```
|
||||
./manage.py qcluster
|
||||
```
|
||||
|
||||
### Note on PGSQL
|
||||
|
||||
|
|
0
matrixhosting/__init__.py
Normal file
0
matrixhosting/__init__.py
Normal file
4
matrixhosting/admin.py
Normal file
4
matrixhosting/admin.py
Normal file
|
@ -0,0 +1,4 @@
|
|||
from django.contrib import admin
|
||||
from .models import VMInstance
|
||||
|
||||
admin.site.register(VMInstance)
|
9
matrixhosting/apps.py
Normal file
9
matrixhosting/apps.py
Normal file
|
@ -0,0 +1,9 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class MatrixhostingConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'matrixhosting'
|
||||
|
||||
def ready(self):
|
||||
from . import signals
|
48
matrixhosting/forms.py
Normal file
48
matrixhosting/forms.py
Normal file
|
@ -0,0 +1,48 @@
|
|||
import tldextract
|
||||
|
||||
from django import forms
|
||||
from django.forms import ModelForm
|
||||
from django.utils.translation import get_language, ugettext_lazy as _
|
||||
from django.core.exceptions import ValidationError
|
||||
from .validators import domain_name_validator
|
||||
from uncloud_pay.models import BillingAddress
|
||||
|
||||
|
||||
class DomainNameField(forms.CharField):
|
||||
description = 'Domain name form field'
|
||||
default_validators = [domain_name_validator, ]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(DomainNameField, self).__init__(*args, **kwargs)
|
||||
|
||||
class RequestHostedVMForm(forms.Form):
|
||||
cores = forms.IntegerField(label='CPU', min_value=1, max_value=48, initial=1)
|
||||
memory = forms.IntegerField(label='RAM', min_value=2, max_value=200, initial=2)
|
||||
storage = forms.IntegerField(label='Storage', min_value=100, max_value=10000, initial=100)
|
||||
matrix_domain = DomainNameField(required=True)
|
||||
homeserver_domain = DomainNameField(required=True)
|
||||
webclient_domain = DomainNameField(required=True)
|
||||
is_open_registration = forms.BooleanField(required=False, initial=False)
|
||||
pricing_name = forms.CharField(required=True)
|
||||
|
||||
def clean(self):
|
||||
homeserver_domain = self.cleaned_data.get('homeserver_domain', False)
|
||||
webclient_domain = self.cleaned_data.get('webclient_domain', False)
|
||||
if homeserver_domain and webclient_domain:
|
||||
# Homserver-Domain and Webclient-Domain cannot be below the same second level domain (i.e. homeserver.abc.ch and webclient.def.cloud are ok,
|
||||
# homeserver.abc.ch and webclient.abc.ch are not ok
|
||||
homeserver_base = tldextract.extract(homeserver_domain).domain
|
||||
webclient_base = tldextract.extract(webclient_domain).domain
|
||||
if homeserver_base == webclient_base:
|
||||
self._errors['webclient_domain'] = self.error_class([
|
||||
'Homserver-Domain and Webclient-Domain cannot be below the same second level domain'])
|
||||
return self.cleaned_data
|
||||
|
||||
|
||||
class BillingAddressForm(ModelForm):
|
||||
class Meta:
|
||||
model = BillingAddress
|
||||
fields = ['full_name', 'street',
|
||||
'city', 'postal_code', 'country', 'vat_number', 'active', 'owner']
|
||||
|
||||
|
30
matrixhosting/migrations/0001_initial.py
Normal file
30
matrixhosting/migrations/0001_initial.py
Normal file
|
@ -0,0 +1,30 @@
|
|||
# Generated by Django 3.2.4 on 2021-06-30 07:42
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='VMPricing',
|
||||
fields=[
|
||||
('id', models.BigAutoField(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=5, default=0, max_digits=7)),
|
||||
('cores_unit_price', models.DecimalField(decimal_places=5, default=0, max_digits=7)),
|
||||
('ram_unit_price', models.DecimalField(decimal_places=5, default=0, max_digits=7)),
|
||||
('storage_unit_price', models.DecimalField(decimal_places=5, 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)),
|
||||
],
|
||||
),
|
||||
]
|
|
@ -0,0 +1,17 @@
|
|||
# Generated by Django 3.2.4 on 2021-07-01 08:48
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('matrixhosting', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameModel(
|
||||
old_name='VMPricing',
|
||||
new_name='MatrixVMPricing',
|
||||
),
|
||||
]
|
33
matrixhosting/migrations/0003_auto_20210703_1523.py
Normal file
33
matrixhosting/migrations/0003_auto_20210703_1523.py
Normal file
|
@ -0,0 +1,33 @@
|
|||
# Generated by Django 3.2.4 on 2021-07-03 15:23
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('matrixhosting', '0002_rename_vmpricing_matrixvmpricing'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='matrixvmpricing',
|
||||
name='cores_unit_price',
|
||||
field=models.DecimalField(decimal_places=2, default=0, max_digits=7),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='matrixvmpricing',
|
||||
name='ram_unit_price',
|
||||
field=models.DecimalField(decimal_places=2, default=0, max_digits=7),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='matrixvmpricing',
|
||||
name='set_up_fees',
|
||||
field=models.DecimalField(decimal_places=2, default=0, max_digits=7),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='matrixvmpricing',
|
||||
name='storage_unit_price',
|
||||
field=models.DecimalField(decimal_places=2, default=0, max_digits=7),
|
||||
),
|
||||
]
|
43
matrixhosting/migrations/0004_matrixhostingorder_vmspecs.py
Normal file
43
matrixhosting/migrations/0004_matrixhostingorder_vmspecs.py
Normal file
|
@ -0,0 +1,43 @@
|
|||
# Generated by Django 3.2.4 on 2021-07-05 06:52
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('uncloud_pay', '0014_auto_20210703_1747'),
|
||||
('matrixhosting', '0003_auto_20210703_1523'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='VMSpecs',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('cores', models.IntegerField(default=1)),
|
||||
('memory', models.IntegerField(default=2)),
|
||||
('storage', models.IntegerField(default=100)),
|
||||
('matrix_domain', models.CharField(max_length=255)),
|
||||
('homeserver_domain', models.CharField(max_length=255)),
|
||||
('webclient_domain', models.CharField(max_length=255)),
|
||||
('is_open_registration', models.BooleanField(default=False, null=True)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='MatrixHostingOrder',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('vm_id', models.IntegerField(default=0)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('status', models.CharField(choices=[('draft', 'Draft'), ('declined', 'Declined'), ('approved', 'Approved')], default='draft', max_length=100)),
|
||||
('stripe_charge_id', models.CharField(max_length=100, null=True)),
|
||||
('price', models.FloatField()),
|
||||
('billing_address', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='uncloud_pay.billingaddress')),
|
||||
('customer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.stripecustomer')),
|
||||
('specs', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, to='matrixhosting.vmspecs')),
|
||||
('vm_pricing', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='matrixhosting.matrixvmpricing')),
|
||||
],
|
||||
),
|
||||
]
|
19
matrixhosting/migrations/0005_auto_20210705_0849.py
Normal file
19
matrixhosting/migrations/0005_auto_20210705_0849.py
Normal file
|
@ -0,0 +1,19 @@
|
|||
# Generated by Django 3.2.4 on 2021-07-05 08:49
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('matrixhosting', '0004_matrixhostingorder_vmspecs'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.DeleteModel(
|
||||
name='MatrixHostingOrder',
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name='VMSpecs',
|
||||
),
|
||||
]
|
16
matrixhosting/migrations/0006_delete_matrixvmpricing.py
Normal file
16
matrixhosting/migrations/0006_delete_matrixvmpricing.py
Normal file
|
@ -0,0 +1,16 @@
|
|||
# Generated by Django 3.2.4 on 2021-07-06 13:21
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('matrixhosting', '0005_auto_20210705_0849'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.DeleteModel(
|
||||
name='MatrixVMPricing',
|
||||
),
|
||||
]
|
31
matrixhosting/migrations/0007_vminstance.py
Normal file
31
matrixhosting/migrations/0007_vminstance.py
Normal file
|
@ -0,0 +1,31 @@
|
|||
# Generated by Django 3.2.4 on 2021-07-09 09:14
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('uncloud_pay', '0021_auto_20210709_0914'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('matrixhosting', '0006_delete_matrixvmpricing'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='VMInstance',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('ip', models.TextField(default='')),
|
||||
('config', models.JSONField()),
|
||||
('creation_date', models.DateTimeField(auto_now_add=True)),
|
||||
('termination_date', models.DateTimeField(blank=True, null=True)),
|
||||
('order', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='instance_id', to='uncloud_pay.order')),
|
||||
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
),
|
||||
]
|
17
matrixhosting/migrations/0008_remove_vminstance_ip.py
Normal file
17
matrixhosting/migrations/0008_remove_vminstance_ip.py
Normal file
|
@ -0,0 +1,17 @@
|
|||
# Generated by Django 3.2.4 on 2021-07-10 14:29
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('matrixhosting', '0007_vminstance'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='vminstance',
|
||||
name='ip',
|
||||
),
|
||||
]
|
19
matrixhosting/migrations/0009_vminstance_vm_id.py
Normal file
19
matrixhosting/migrations/0009_vminstance_vm_id.py
Normal file
|
@ -0,0 +1,19 @@
|
|||
# Generated by Django 3.2.4 on 2021-07-13 10:20
|
||||
|
||||
from django.db import migrations, models
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('matrixhosting', '0008_remove_vminstance_ip'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='vminstance',
|
||||
name='vm_id',
|
||||
field=models.UUIDField(default=uuid.uuid4, editable=False, unique=True),
|
||||
),
|
||||
]
|
0
matrixhosting/migrations/__init__.py
Normal file
0
matrixhosting/migrations/__init__.py
Normal file
77
matrixhosting/models.py
Normal file
77
matrixhosting/models.py
Normal file
|
@ -0,0 +1,77 @@
|
|||
import logging
|
||||
import uuid
|
||||
import os
|
||||
import sys
|
||||
import gitlab
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
from django.db import models
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.template.loader import render_to_string
|
||||
|
||||
from uncloud_pay.models import Order
|
||||
|
||||
|
||||
# Initialize logger.
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class VMInstance(models.Model):
|
||||
owner = models.ForeignKey(get_user_model(),
|
||||
on_delete=models.CASCADE,
|
||||
editable=True)
|
||||
|
||||
vm_id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
|
||||
|
||||
config = models.JSONField(null=False, blank=False)
|
||||
|
||||
order = models.OneToOneField(Order, on_delete=models.CASCADE, related_name='instance_id')
|
||||
|
||||
creation_date = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
termination_date = models.DateTimeField(blank=True, null=True)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# Read the deployment yaml file and render the template
|
||||
# Then save it as new yaml file and push it to github repo
|
||||
if 'test' in sys.argv:
|
||||
return super().save(*args, **kwargs)
|
||||
template_dir = os.path.join(os.path.dirname(__file__), 'yaml')
|
||||
env = Environment(loader = FileSystemLoader(template_dir),autoescape = True)
|
||||
tmpl = env.get_template('deployment.yaml.tmpl')
|
||||
result = tmpl.render(
|
||||
name=self.vm_id
|
||||
)
|
||||
gl = gitlab.Gitlab(settings.GITLAB_SERVER, oauth_token=settings.GITLAB_OAUTH_TOKEN)
|
||||
project = gl.projects.get(settings.GITLAB_PROJECT_ID)
|
||||
project.files.create({'file_path': settings.GITLAB_YAML_DIR + f'{self.vm_id}.yaml',
|
||||
'branch': 'master',
|
||||
'content': result,
|
||||
'author_email': settings.GITLAB_AUTHOR_EMAIL,
|
||||
'author_name': settings.GITLAB_AUTHOR_NAME,
|
||||
'commit_message': f'Add New Deployment for {self.vm_id}'})
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
# Delete the deployment yaml file first then
|
||||
# Then delete it
|
||||
if 'test' in sys.argv:
|
||||
return super().delete(*args, **kwargs)
|
||||
gl = gitlab.Gitlab(settings.GITLAB_SERVER, oauth_token=settings.GITLAB_OAUTH_TOKEN)
|
||||
project = gl.projects.get(settings.GITLAB_PROJECT_ID)
|
||||
f_path = settings.GITLAB_YAML_DIR + f'{self.vm_id}.yaml'
|
||||
file = project.files.get(file_path=f_path, ref='master')
|
||||
if file:
|
||||
project.files.delete(file_path=f_path,
|
||||
commit_message=f'Delete {self.vm_id}', branch='master',
|
||||
author_email=settings.GITLAB_AUTHOR_EMAIL,
|
||||
author_name=settings.GITLAB_AUTHOR_NAME)
|
||||
|
||||
super().delete(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.id}-{self.order}"
|
||||
|
||||
def delete_for_bill(self, bill):
|
||||
#TODO delete related instances
|
||||
return True
|
8
matrixhosting/serializers.py
Normal file
8
matrixhosting/serializers.py
Normal file
|
@ -0,0 +1,8 @@
|
|||
from rest_framework import serializers
|
||||
|
||||
from .models import *
|
||||
|
||||
class VMInstanceSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = VMInstance
|
||||
fields = '__all__'
|
10
matrixhosting/signals.py
Normal file
10
matrixhosting/signals.py
Normal file
|
@ -0,0 +1,10 @@
|
|||
from matrixhosting.models import VMInstance
|
||||
from uncloud_pay.models import Order
|
||||
from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
|
||||
@receiver(post_save, sender=Order)
|
||||
def create_instance(sender, instance, created, **kwargs):
|
||||
machine = VMInstance.objects.filter(order=instance).first()
|
||||
if not machine:
|
||||
VMInstance.objects.create(owner=instance.owner, order=instance, config=instance.config)
|
1346
matrixhosting/static/matrixhosting/css/common.css
Normal file
1346
matrixhosting/static/matrixhosting/css/common.css
Normal file
File diff suppressed because it is too large
Load diff
618
matrixhosting/static/matrixhosting/css/hosting.css
Normal file
618
matrixhosting/static/matrixhosting/css/hosting.css
Normal file
|
@ -0,0 +1,618 @@
|
|||
.navbar-transparent #logoWhite {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.navbar-transparent #logoBlack {
|
||||
display: block;
|
||||
width: 220px;
|
||||
}
|
||||
|
||||
.topnav .navbar-fixed-top .navbar-collapse {
|
||||
max-height: 740px;
|
||||
}
|
||||
|
||||
.navbar-default .navbar-header {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.navbar-right .highlights-dropdown .dropdown-menu {
|
||||
left: 0 !important;
|
||||
min-width: 155px;
|
||||
margin-left: 15px;
|
||||
padding: 0 5px 8px !important;
|
||||
}
|
||||
|
||||
@media(min-width: 768px) {
|
||||
.navbar-default .navbar-nav>li a,
|
||||
.navbar-right .highlights-dropdown .dropdown-menu>li a {
|
||||
font-weight: 300;
|
||||
}
|
||||
.navbar-right .highlights-dropdown .dropdown-menu {
|
||||
border-width: 0 0 1px 0;
|
||||
border-color: #e7e7e7;
|
||||
box-shadow: -8px 14px 20px -5px rgba(77, 77, 77, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
.navbar-right .highlights-dropdown .dropdown-menu>li a {
|
||||
font-size: 13px;
|
||||
font-family: 'Lato', sans-serif;
|
||||
padding: 1px 10px 1px 18px !important;
|
||||
background: transparent;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.navbar-right .highlights-dropdown .dropdown-menu>li a:hover,
|
||||
.navbar-right .highlights-dropdown .dropdown-menu>li a:focus,
|
||||
.navbar-right .highlights-dropdown .dropdown-menu>li a:active {
|
||||
background: transparent;
|
||||
text-decoration: underline !important;
|
||||
}
|
||||
|
||||
.un-icon {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
opacity: 0.5;
|
||||
margin-top: -1px;
|
||||
}
|
||||
|
||||
|
||||
/***** DCL payment page **********/
|
||||
|
||||
.dcl-order-container {
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.dcl-place-order-text {
|
||||
color: #808080;
|
||||
}
|
||||
|
||||
.card-warning-content {
|
||||
font-weight: 300;
|
||||
border: 1px solid #a1a1a1;
|
||||
border-radius: 3px;
|
||||
padding: 5px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.card-warning-error {
|
||||
border: 1px solid #EB4D5C;
|
||||
color: #EB4D5C;
|
||||
}
|
||||
|
||||
.card-warning-addtional-margin {
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.card-cvc-element label {
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
.card-element {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.card-element label {
|
||||
width: 100%;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
.my-input {
|
||||
border-bottom: 1px solid #ccc;
|
||||
}
|
||||
|
||||
.card-cvc-element .my-input {
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
#card-errors {
|
||||
clear: both;
|
||||
padding: 0 0 10px;
|
||||
color: #eb4d5c;
|
||||
}
|
||||
|
||||
.credit-card-goup {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.card-expiry-element {
|
||||
padding-right: 10px;
|
||||
}
|
||||
|
||||
.card-cvc-element {
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
#billing-form .form-control {
|
||||
box-shadow: none !important;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1200px) {
|
||||
.dcl-order-container {
|
||||
width: 990px;
|
||||
padding: 0 15px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
.footer-vm p.copyright {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.navbar-default .navbar-nav>.open>a,
|
||||
.navbar-default .navbar-nav>.open>a:focus,
|
||||
.navbar-default .navbar-nav>.open>a:hover,
|
||||
.navbar-default .navbar-nav>.active>a,
|
||||
.navbar-default .navbar-nav>.active>a:focus,
|
||||
.navbar-default .navbar-nav>.active>a:hover {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.navbar-default .navbar-nav .open .dropdown-menu>.active a,
|
||||
.navbar-default .navbar-nav .open .dropdown-menu>.active a:focus,
|
||||
.navbar-default .navbar-nav .open .dropdown-menu>.active a:hover {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* bootstrap input box-shadow disable */
|
||||
|
||||
.has-error .form-control:focus,
|
||||
.has-error .form-control:active,
|
||||
.has-success .form-control:focus,
|
||||
.has-success .form-control:active {
|
||||
box-shadow: inset 0 0 1px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.content-dashboard {
|
||||
min-height: calc(100vh - 96px);
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
max-width: 1120px;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.content-dashboard {
|
||||
padding: 0 15px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 575px) {
|
||||
select {
|
||||
width: 280px;
|
||||
}
|
||||
}
|
||||
|
||||
.btn:focus,
|
||||
.btn:active:focus {
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/***********Styles for Model********************/
|
||||
|
||||
.modal-content {
|
||||
border-radius: 0px;
|
||||
font-family: Lato, "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
width: 100%;
|
||||
float: left;
|
||||
border-radius: 0;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
min-height: 30px;
|
||||
border-bottom: 0px solid #e5e5e5;
|
||||
padding: 0px 15px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.modal-header .close {
|
||||
font-size: 75px;
|
||||
font-weight: 300;
|
||||
margin-top: 0;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 11px;
|
||||
z-index: 10;
|
||||
line-height: 60px;
|
||||
}
|
||||
|
||||
.modal-header .close span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.modal-header .close:focus {
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
float: left;
|
||||
padding: 0px 30px 15px 30px;
|
||||
}
|
||||
|
||||
.modal-body .modal-icon i {
|
||||
font-size: 80px;
|
||||
font-weight: 100;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.modal-body .modal-icon {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
margin: 0;
|
||||
line-height: 1.42857143;
|
||||
font-size: 25px;
|
||||
padding: 0;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.modal-text {
|
||||
padding-top: 5px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.modal-text p:not(:last-of-type) {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.modal-title+.modal-footer {
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
border-top: 0px solid #e5e5e5;
|
||||
width: 100%;
|
||||
float: left;
|
||||
text-align: center;
|
||||
padding: 15px 15px;
|
||||
}
|
||||
|
||||
.modal {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.modal-dialog {
|
||||
display: inline-block;
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
width: 40%;
|
||||
margin: 15px auto;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) and (max-width: 991px) {
|
||||
.modal-dialog {
|
||||
width: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.modal-dialog {
|
||||
width: 95%;
|
||||
}
|
||||
}
|
||||
|
||||
@media(min-width: 576px) {
|
||||
.modal:before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
height: 100%;
|
||||
vertical-align: middle;
|
||||
margin-right: -4px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* ========= */
|
||||
|
||||
.btn-wide {
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.choice-btn {
|
||||
min-width: 110px;
|
||||
background-color: #3C5480;
|
||||
color: #fff;
|
||||
border: 2px solid #3C5480;
|
||||
padding: 4px 10px;
|
||||
transition: 0.3s all ease-out;
|
||||
}
|
||||
|
||||
.choice-btn:focus,
|
||||
.choice-btn:hover,
|
||||
.choice-btn:active {
|
||||
color: #3C5480;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.choice-btn {
|
||||
margin-top: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
.payment-container {
|
||||
padding-top: 70px;
|
||||
padding-bottom: 11%;
|
||||
}
|
||||
|
||||
.last-p {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.dcl-payment-section {
|
||||
max-width: 391px;
|
||||
margin: 0 auto 30px;
|
||||
padding: 0 10px 30px;
|
||||
border-bottom: 1px solid #edebeb;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.dcl-payment-section hr {
|
||||
margin-top: 15px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.dcl-payment-section .top-hr {
|
||||
margin-left: -10px;
|
||||
}
|
||||
|
||||
.dcl-payment-section h3 {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dcl-payment-section p {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.dcl-payment-section .card-warning-content {
|
||||
padding: 8px 10px;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.dcl-payment-order strong {
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.dcl-payment-order p {
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.dcl-payment-section .form-group {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.dcl-payment-section .form-control {
|
||||
box-shadow: none;
|
||||
padding: 6px 12px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.dcl-payment-user {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.dcl-payment-user h4 {
|
||||
font-weight: 600;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.dcl-payment-grid {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.dcl-payment-box {
|
||||
width: 50%;
|
||||
position: relative;
|
||||
padding: 0 30px;
|
||||
}
|
||||
.dcl-payment-box:nth-child(2) {
|
||||
order: 1;
|
||||
}
|
||||
.dcl-payment-box:nth-child(4) {
|
||||
order: 2;
|
||||
}
|
||||
.dcl-payment-section {
|
||||
padding-top: 15px;
|
||||
padding-bottom: 15px;
|
||||
margin-bottom: 0;
|
||||
border-bottom-width: 5px;
|
||||
}
|
||||
.dcl-payment-box:nth-child(2n) .dcl-payment-section {
|
||||
border-bottom: none;
|
||||
}
|
||||
.dcl-payment-box:nth-child(1):after,
|
||||
.dcl-payment-box:nth-child(2):after {
|
||||
content: ' ';
|
||||
display: block;
|
||||
background: #eee;
|
||||
width: 1px;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
z-index: 2;
|
||||
top: 20px;
|
||||
bottom: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
#virtual_machine_create_form {
|
||||
padding: 15px 0;
|
||||
}
|
||||
|
||||
.btn-vm-contact {
|
||||
color: #fff;
|
||||
background: #A3C0E2;
|
||||
border: 2px solid #A3C0E2;
|
||||
padding: 5px 25px;
|
||||
font-size: 12px;
|
||||
letter-spacing: 1.3px;
|
||||
}
|
||||
|
||||
.btn-vm-contact:hover,
|
||||
.btn-vm-contact:focus {
|
||||
background: #fff;
|
||||
color: #a3c0e2;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* hosting-order */
|
||||
|
||||
.order-detail-container {
|
||||
max-width: 600px;
|
||||
margin: 100px auto 40px;
|
||||
border: 1px solid #ccc;
|
||||
padding: 30px 30px 20px;
|
||||
color: #595959;
|
||||
}
|
||||
|
||||
.order-detail-container .dashboard-title-thin {
|
||||
margin-top: 0;
|
||||
margin-left: -3px;
|
||||
}
|
||||
|
||||
.order-detail-container .dashboard-title-thin .un-icon {
|
||||
margin-top: -6px;
|
||||
}
|
||||
|
||||
.order-detail-container .dashboard-container-head {
|
||||
position: relative;
|
||||
padding: 0;
|
||||
margin-bottom: 38px;
|
||||
}
|
||||
|
||||
.order-detail-container .order-details {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.order-detail-container h4 {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.order-detail-container p {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.order-detail-container hr {
|
||||
margin: 15px 0;
|
||||
}
|
||||
|
||||
.order-detail-container .thin-hr {
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.order-detail-container .subtotal-price {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.order-detail-container .subtotal-price .text-primary {
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.order-detail-container .total-price {
|
||||
font-size: 18px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.order-detail-container {
|
||||
padding: 15px;
|
||||
}
|
||||
.order-confirm-btn {
|
||||
text-align: center;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.order-detail-container .dashboard-container-options {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: -4px;
|
||||
}
|
||||
.order-detail-container .dashboard-container-options .svg-img {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.order_detail_footer {
|
||||
font-size: 9px;
|
||||
letter-spacing: 1px;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.order_detail_footer strong {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.order_detail_footer small {
|
||||
font-size: 8px;
|
||||
}
|
||||
|
||||
.dashboard-title-thin {
|
||||
font-weight: 300;
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.dashboard-title-thin .un-icon {
|
||||
height: 34px;
|
||||
margin-right: 5px;
|
||||
margin-top: -2px;
|
||||
width: 34px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
@media (max-width:767px) {
|
||||
.dashboard-title-thin {
|
||||
font-size: 22px;
|
||||
}
|
||||
.dashboard-title-thin .un-icon {
|
||||
height: 22px;
|
||||
width: 22px;
|
||||
margin-top: -3px;
|
||||
}
|
||||
}
|
||||
|
||||
.locale_date {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.locale_date.done {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.btn-vm-back {
|
||||
color: #fff;
|
||||
background: #C4CEDA;
|
||||
border: 2px solid #C4CEDA;
|
||||
padding: 5px 25px;
|
||||
font-size: 12px;
|
||||
letter-spacing: 1.3px;
|
||||
}
|
||||
|
||||
.btn-vm-back:hover,
|
||||
.btn-vm-back:focus {
|
||||
color: #fff;
|
||||
background: #8da4c0;
|
||||
border-color: #8da4c0;
|
||||
}
|
||||
|
46
matrixhosting/static/matrixhosting/js/main.js
Normal file
46
matrixhosting/static/matrixhosting/js/main.js
Normal file
|
@ -0,0 +1,46 @@
|
|||
(function($) {
|
||||
"use strict"; // Start of use strict
|
||||
|
||||
$(document).ready(function() {
|
||||
function fetch_pricing() {
|
||||
var url = '/matrix/pricing/' + $('#pricing_name').val() + '/calculate/';
|
||||
var cores = $('#cores').val();
|
||||
var memory = $('#memory').val();
|
||||
var storage = $('#storage').val();
|
||||
$.ajax({
|
||||
type: 'GET',
|
||||
url: url,
|
||||
data: { cores: cores, memory: memory, storage: storage},
|
||||
dataType: 'json',
|
||||
success: function (data) {
|
||||
if (data && data['price']) {
|
||||
$('#total').text(data['price']);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
function incrementValue(e) {
|
||||
var valueElement = $(e.target).parent().parent().find('input');
|
||||
var step = $(valueElement).attr('step');
|
||||
var min = parseInt($(valueElement).attr('min'));
|
||||
var max = parseInt($(valueElement).attr('max'));
|
||||
var new_value = 0;
|
||||
if (e.data.inc == 1) {
|
||||
new_value = Math.min(parseInt($(valueElement).val()) + parseInt(step) * e.data.inc, max);
|
||||
} else {
|
||||
new_value = Math.max(parseInt($(valueElement).val()) + parseInt(step) * e.data.inc, min);
|
||||
}
|
||||
$(valueElement).val(new_value);
|
||||
fetch_pricing();
|
||||
return false;
|
||||
};
|
||||
if ($('#pricing_name') != undefined) {
|
||||
fetch_pricing();
|
||||
}
|
||||
|
||||
$('.fa-plus-circle.right').bind('click', {inc: 1}, incrementValue);
|
||||
|
||||
$('.fa-minus-circle.left').bind('click', {inc: -1}, incrementValue);
|
||||
});
|
||||
})(jQuery);
|
36
matrixhosting/static/matrixhosting/js/order.js
Normal file
36
matrixhosting/static/matrixhosting/js/order.js
Normal file
|
@ -0,0 +1,36 @@
|
|||
$( document ).ready(function() {
|
||||
var create_vm_form = $('#virtual_machine_create_form');
|
||||
create_vm_form.submit(placeOrderPayment);
|
||||
function placeOrderPayment(e) {
|
||||
e.preventDefault();
|
||||
$.ajax({
|
||||
url: create_vm_form.attr('action'),
|
||||
type: 'POST',
|
||||
data: create_vm_form.serialize(),
|
||||
init: function () {
|
||||
ok_btn = $('#createvm-modal-done-btn');
|
||||
close_btn = $('#createvm-modal-close-btn');
|
||||
ok_btn.addClass('btn btn-success btn-ok btn-wide hide');
|
||||
close_btn.addClass('btn btn-danger btn-ok btn-wide hide');
|
||||
},
|
||||
success: function (data) {
|
||||
fa_icon = $('.modal-icon').find('.fa-cog');
|
||||
modal_btn = $('#createvm-modal-done-btn');
|
||||
if (data.error) {
|
||||
// Display error.message in your UI.
|
||||
modal_btn.attr('href', error_url).removeClass('visually-hidden');
|
||||
fa_icon.attr('class', 'fa fa-close');
|
||||
modal_btn.attr('class', '').addClass('btn btn-danger btn-ok btn-wide');
|
||||
$('#createvm-modal-title').text("Error Occurred");
|
||||
$('#createvm-modal-body').html(data.error.message);
|
||||
} else {
|
||||
// The payment has succeeded
|
||||
// Display a success message
|
||||
modal_btn.attr('href', data.redirect).removeClass('visually-hidden');
|
||||
$('#createvm-modal-title').text("Order Succeeded");
|
||||
$('#createvm-modal-body').html("Order has been added and the instance will be ready soon");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
204
matrixhosting/static/matrixhosting/js/payment.js
Normal file
204
matrixhosting/static/matrixhosting/js/payment.js
Normal file
|
@ -0,0 +1,204 @@
|
|||
var cardBrandToPfClass = {
|
||||
'visa': 'pf-visa',
|
||||
'mastercard': 'pf-mastercard',
|
||||
'amex': 'pf-american-express',
|
||||
'discover': 'pf-discover',
|
||||
'diners': 'pf-diners',
|
||||
'jcb': 'pf-jcb',
|
||||
'unknown': 'pf-credit-card'
|
||||
};
|
||||
function setBrandIcon(brand) {
|
||||
var brandIconElement = document.getElementById('brand-icon');
|
||||
var pfClass = 'pf-credit-card';
|
||||
if (brand in cardBrandToPfClass) {
|
||||
pfClass = cardBrandToPfClass[brand];
|
||||
}
|
||||
for (var i = brandIconElement.classList.length - 1; i >= 0; i--) {
|
||||
brandIconElement.classList.remove(brandIconElement.classList[i]);
|
||||
}
|
||||
brandIconElement.classList.add('pf');
|
||||
brandIconElement.classList.add(pfClass);
|
||||
}
|
||||
|
||||
|
||||
$(document).ready(function () {
|
||||
$.ajaxSetup({
|
||||
beforeSend: function (xhr, settings) {
|
||||
function getCookie(name) {
|
||||
var cookieValue = null;
|
||||
if (document.cookie && document.cookie != '') {
|
||||
var cookies = document.cookie.split(';');
|
||||
for (var i = 0; i < cookies.length; i++) {
|
||||
var cookie = jQuery.trim(cookies[i]);
|
||||
// Does this cookie string begin with the name we want?
|
||||
if (cookie.substring(0, name.length + 1) == (name + '=')) {
|
||||
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return cookieValue;
|
||||
}
|
||||
|
||||
if (!(/^http:.*/.test(settings.url) || /^https:.*/.test(settings.url))) {
|
||||
// Only send the token to relative URLs i.e. locally.
|
||||
xhr.setRequestHeader("X-CSRFToken", getCookie('csrftoken'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
var hasCreditcard = window.hasCreditcard || false;
|
||||
if (!hasCreditcard && window.stripeKey) {
|
||||
var stripe = Stripe(window.stripeKey);
|
||||
if (window.pm_id) {
|
||||
|
||||
} else {
|
||||
var element_style = {
|
||||
fonts: [{
|
||||
family: 'lato-light',
|
||||
src: 'url(https://cdn.jsdelivr.net/font-lato/2.0/Lato/Lato-Light.woff) format("woff2")'
|
||||
}, {
|
||||
family: 'lato-regular',
|
||||
src: 'url(https://cdn.jsdelivr.net/font-lato/2.0/Lato/Lato-Regular.woff) format("woff2")'
|
||||
}
|
||||
],
|
||||
locale: window.current_lan
|
||||
};
|
||||
var elements = stripe.elements(element_style);
|
||||
var credit_card_text_style = {
|
||||
base: {
|
||||
iconColor: '#666EE8',
|
||||
color: '#31325F',
|
||||
lineHeight: '25px',
|
||||
fontWeight: 300,
|
||||
fontFamily: "'lato-light', sans-serif",
|
||||
fontSize: '14px',
|
||||
'::placeholder': {
|
||||
color: '#777'
|
||||
}
|
||||
},
|
||||
invalid: {
|
||||
iconColor: '#eb4d5c',
|
||||
color: '#eb4d5c',
|
||||
lineHeight: '25px',
|
||||
fontWeight: 300,
|
||||
fontFamily: "'lato-regular', sans-serif",
|
||||
fontSize: '14px',
|
||||
'::placeholder': {
|
||||
color: '#eb4d5c',
|
||||
fontWeight: 400
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var enter_ccard_text = "Enter your credit card number";
|
||||
if (typeof window.enter_your_card_text !== 'undefined') {
|
||||
enter_ccard_text = window.enter_your_card_text;
|
||||
}
|
||||
var cardNumberElement = elements.create('cardNumber', {
|
||||
style: credit_card_text_style,
|
||||
placeholder: enter_ccard_text
|
||||
});
|
||||
cardNumberElement.mount('#card-number-element');
|
||||
|
||||
var cardExpiryElement = elements.create('cardExpiry', {
|
||||
style: credit_card_text_style
|
||||
});
|
||||
cardExpiryElement.mount('#card-expiry-element');
|
||||
|
||||
var cardCvcElement = elements.create('cardCvc', {
|
||||
style: credit_card_text_style
|
||||
});
|
||||
cardCvcElement.mount('#card-cvc-element');
|
||||
cardNumberElement.on('change', function (event) {
|
||||
if (event.brand) {
|
||||
setBrandIcon(event.brand);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function submitBillingForm(pmId) {
|
||||
var billing_form = $('#billing-form');
|
||||
billing_form.append('<input type="hidden" name="id_payment_method" value="' + pmId + '" />');
|
||||
billing_form.submit();
|
||||
}
|
||||
|
||||
var $form_new = $('#payment-form-new');
|
||||
$form_new.submit(payWithPaymentIntent);
|
||||
window.result = "";
|
||||
window.card = "";
|
||||
function payWithPaymentIntent(e) {
|
||||
e.preventDefault();
|
||||
function stripePMHandler(paymentMethod) {
|
||||
// Insert the token ID into the form so it gets submitted to the server
|
||||
console.log(paymentMethod);
|
||||
$('#id_payment_method').val(paymentMethod.id);
|
||||
submitBillingForm(paymentMethod.id);
|
||||
}
|
||||
stripe.createPaymentMethod({
|
||||
type: 'card',
|
||||
card: cardNumberElement,
|
||||
})
|
||||
.then(function(result) {
|
||||
// Handle result.error or result.paymentMethod
|
||||
window.result = result;
|
||||
if(result.error) {
|
||||
var errorElement = document.getElementById('card-errors');
|
||||
errorElement.textContent = result.error.message;
|
||||
} else {
|
||||
console.log("created paymentMethod " + result.paymentMethod.id);
|
||||
stripePMHandler(result.paymentMethod);
|
||||
}
|
||||
});
|
||||
window.card = cardNumberElement;
|
||||
}
|
||||
|
||||
/* Form validation */
|
||||
$.validator.addMethod("month", function (value, element) {
|
||||
return this.optional(element) || /^(01|02|03|04|05|06|07|08|09|10|11|12)$/.test(value);
|
||||
}, "Please specify a valid 2-digit month.");
|
||||
|
||||
$.validator.addMethod("year", function (value, element) {
|
||||
return this.optional(element) || /^[0-9]{2}$/.test(value);
|
||||
}, "Please specify a valid 2-digit year.");
|
||||
|
||||
validator = $form_new.validate({
|
||||
rules: {
|
||||
cardNumber: {
|
||||
required: true,
|
||||
creditcard: true,
|
||||
digits: true
|
||||
},
|
||||
expMonth: {
|
||||
required: true,
|
||||
month: true
|
||||
},
|
||||
expYear: {
|
||||
required: true,
|
||||
year: true
|
||||
},
|
||||
cvCode: {
|
||||
required: true,
|
||||
digits: true
|
||||
}
|
||||
},
|
||||
highlight: function (element) {
|
||||
$(element).closest('.form-control').removeClass('success').addClass('error');
|
||||
},
|
||||
unhighlight: function (element) {
|
||||
$(element).closest('.form-control').removeClass('error').addClass('success');
|
||||
},
|
||||
errorPlacement: function (error, element) {
|
||||
$(element).closest('.form-group').append(error);
|
||||
}
|
||||
});
|
||||
|
||||
$('.credit-card-info .btn.choice-btn').click(function () {
|
||||
var id = this.dataset['id_card'];
|
||||
$('#id_card').val(id);
|
||||
submitBillingForm(id);
|
||||
});
|
||||
|
||||
});
|
64
matrixhosting/tasks.py
Normal file
64
matrixhosting/tasks.py
Normal file
|
@ -0,0 +1,64 @@
|
|||
import logging
|
||||
from datetime import date, timedelta, timezone
|
||||
from django.conf import settings
|
||||
from django.template.loader import render_to_string
|
||||
from django_q.tasks import async_task, schedule
|
||||
from django_q.models import Schedule
|
||||
from django.db.models import Q
|
||||
from uncloud_pay.models import Bill, Payment
|
||||
from uncloud_pay.selectors import has_enough_balance, get_balance_for_user
|
||||
from .models import VMInstance
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
def send_warning_email(bill, html_message):
|
||||
schedule('django.core.mail.send_mail',
|
||||
'Renewal Warning',
|
||||
None,
|
||||
settings.RENEWAL_FROM_EMAIL,
|
||||
[bill.owner.email],
|
||||
html_message,
|
||||
schedule_type=Schedule.ONCE,
|
||||
next_run=timezone.now() + timedelta(hours=1))
|
||||
|
||||
def charge_open_bills():
|
||||
un_paid_bills = Bill.objects.filter(is_closed=False)
|
||||
for bill in un_paid_bills:
|
||||
date_diff = (date.today() - bill.due_date.date()).days
|
||||
# If there is not enough money in the account 7 days before renewal, the system sends a warning
|
||||
# If there is not enough money in the account 3 days before renewal, the system sends a 2nd warning
|
||||
# If on renewal date there is not enough money in the account, delete the instance
|
||||
if date_diff == 7:
|
||||
if not has_enough_balance(bill.owner):
|
||||
context = {'name': bill.owner.name, 'message': "You don't have enough balance for renewal... upload to your account _here"}
|
||||
html_message = render_to_string('matrixhosting/emails/renewal_warning.html', context)
|
||||
send_warning_email(bill, html_message)
|
||||
elif date_diff == 3:
|
||||
if not has_enough_balance(bill.owner):
|
||||
context = {'name': bill.owner.name, 'message': "You don't have enough balance for renewal... Your instance will be deleted in 3 days"}
|
||||
html_message = render_to_string('matrixhosting/emails/renewal_warning.html', context)
|
||||
send_warning_email(bill, html_message)
|
||||
elif date_diff <= 0:
|
||||
if not has_enough_balance(bill.owner):
|
||||
VMInstance.delete_for_bill(bill)
|
||||
else:
|
||||
try:
|
||||
balance = get_balance_for_user(bill.owner)
|
||||
if balance < 0:
|
||||
payment = Payment.objects.create(owner=bill.owner, amount=balance, source='stripe')
|
||||
if payment:
|
||||
bill.close()
|
||||
bill.close()
|
||||
except Exception as e:
|
||||
log.error(f"It seems that there is issue in payment for {bill.owner.name}", e)
|
||||
# do nothing
|
||||
|
||||
|
||||
def process_recurring_orders():
|
||||
"""
|
||||
Check for pending recurring and charge it and generate bills or send the customer warning
|
||||
"""
|
||||
Bill.create_bills_for_all_users()
|
||||
|
||||
def delete_instance(instance_id):
|
||||
VMInstance.objects.delete(instance_id)
|
60
matrixhosting/templates/matrixhosting/base.html
Normal file
60
matrixhosting/templates/matrixhosting/base.html
Normal file
|
@ -0,0 +1,60 @@
|
|||
{% load static i18n %}
|
||||
{% get_current_language as LANGUAGE_CODE %}
|
||||
{% load bootstrap5 %}
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{LANGUAGE_CODE}}">
|
||||
<head>
|
||||
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="description" content="Matrix Hosting by ungleich">
|
||||
<meta name="author" content="ungleich glarus ag">
|
||||
<title>Matrix Hosting - {% block title %} made in Switzerland{% endblock %}</title>
|
||||
|
||||
<!-- Vendor CSS -->
|
||||
<!-- Bootstrap Core CSS -->
|
||||
{% bootstrap_css %}
|
||||
<!-- Icon Fonts -->
|
||||
<link href="{% static 'fontawesome_free/css/all.min.css' %}" rel="stylesheet" type="text/css">
|
||||
<!-- Custom CSS -->
|
||||
<link href="{% static 'matrixhosting/css/common.css' %}" rel="stylesheet">
|
||||
{% block css_extra %}
|
||||
{% endblock css_extra %}
|
||||
|
||||
<!-- External Fonts -->
|
||||
<link href="//fonts.googleapis.com/css?family=Lato:300,400,600,700" rel="stylesheet" type="text/css">
|
||||
|
||||
<!-- HTML5 Shim and Respond.js IE8 support of HTML5 elements and media queries -->
|
||||
<!-- WARNING: Respond.js doesn't work if you view the page via file:// -->
|
||||
<!--[if lt IE 9]>
|
||||
<script src="https://oss.maxcdn.com/libs/html5shiv/3.7.0/html5shiv.js"></script>
|
||||
<script src="https://oss.maxcdn.com/libs/respond.js/1.4.2/respond.min.js"></script>
|
||||
<![endif]-->
|
||||
|
||||
<!-- Google analytics -->
|
||||
<!-- End Google Analytics -->
|
||||
</head>
|
||||
|
||||
<body>
|
||||
{% block navbar %}
|
||||
{% include "matrixhosting/includes/_navbar.html" %}
|
||||
{% endblock navbar %}
|
||||
|
||||
{% block content %}
|
||||
{% endblock %}
|
||||
|
||||
{% include "matrixhosting/includes/_footer.html" %}
|
||||
|
||||
<!-- jQuery -->
|
||||
<script src="https://code.jquery.com/jquery-3.6.0.min.js" integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4=" crossorigin="anonymous"></script>
|
||||
<script src="{% static 'fontawesome_free/js/all.min.js' %}"></script>
|
||||
<!-- Bootstrap Core JavaScript -->
|
||||
{% bootstrap_javascript %}
|
||||
<!-- Custom JS -->
|
||||
<script src="{% static 'matrixhosting/js/main.js' %}"></script>
|
||||
{% block js_extra %}
|
||||
{% endblock js_extra %}
|
||||
</body>
|
||||
</html>
|
127
matrixhosting/templates/matrixhosting/dashboard.html
Normal file
127
matrixhosting/templates/matrixhosting/dashboard.html
Normal file
|
@ -0,0 +1,127 @@
|
|||
{% extends "matrixhosting/base.html" %} {% load static i18n %}
|
||||
{% block content%}
|
||||
<!-- Page Content -->
|
||||
{% csrf_token %}
|
||||
<div>
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">#</th>
|
||||
<th scope="col">Description</th>
|
||||
<th scope="col">Starting At</th>
|
||||
<th scope="col">Config</th>
|
||||
<th scope="col">Pricing Plan</th>
|
||||
<th scope="col">OneTime Price</th>
|
||||
<th scope="col">Recurring Price</th>
|
||||
<th scope="col">Ending At</th>
|
||||
<th scope="col"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for object in object_list %}
|
||||
<tr data-id="{{object.id}}">
|
||||
<th scope="row">{{ object.id }}</th>
|
||||
<td>{{ object.description }}</td>
|
||||
<td>{{ object.starting_date }}</td>
|
||||
<td>{{ object.config }}</td>
|
||||
<td>{{ object.pricing_plan}}</td>
|
||||
<td>{{ object.one_time_price }}</td>
|
||||
<td>{{ object.recurring_price }}</td>
|
||||
<td>{{ object.ending_date }}</td>
|
||||
{% if object.ending_date %}
|
||||
<td></td>
|
||||
{% else %}
|
||||
<td>
|
||||
<button
|
||||
class="btn btn-danger btn-sm cancel-subscription"
|
||||
type="submit"
|
||||
name="action"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="modal fade"
|
||||
tabindex="-1"
|
||||
role="dialog"
|
||||
aria-labelledby="mySmallModalLabel"
|
||||
aria-hidden="true"
|
||||
id="mi-modal"
|
||||
>
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title" id="myModalLabel">Cancel Subscription</h4>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>
|
||||
Are you sure that you want to cancel this subscription?. </p>
|
||||
<p>
|
||||
The instance will be active till the end date of the last bill and will be deleted
|
||||
after that.
|
||||
</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-default" id="modal-btn-yes">
|
||||
Yes
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" id="modal-btn-no">
|
||||
No
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert" role="alert" id="result"></div>
|
||||
<!-- /.banner -->
|
||||
{% endblock %}
|
||||
|
||||
{% block js_extra %}
|
||||
<script type="text/javascript">
|
||||
var modalConfirm = function (callback) {
|
||||
$(".cancel-subscription").on("click", function (event) {
|
||||
$('.selected').removeClass('selected');
|
||||
$(event.target).parent().parent().addClass('selected');
|
||||
$("#mi-modal").modal("show");
|
||||
});
|
||||
|
||||
$("#modal-btn-yes").on("click", function () {
|
||||
callback(true);
|
||||
});
|
||||
|
||||
$("#modal-btn-no").on("click", function () {
|
||||
callback(false);
|
||||
$("#mi-modal").modal("hide");
|
||||
});
|
||||
};
|
||||
|
||||
modalConfirm(function (confirm) {
|
||||
if (confirm) {
|
||||
var selected_order = $('.selected').data('id');
|
||||
$.ajax({
|
||||
url: '{% url "matrix:dashboard" %}',
|
||||
type: 'POST',
|
||||
data: {'order_id': selected_order, 'csrfmiddlewaretoken': '{{ csrf_token }}',},
|
||||
success: function (data) {
|
||||
$("#mi-modal").modal("hide");
|
||||
window.location.reload();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
|
@ -0,0 +1,13 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Renewal Warning</title>
|
||||
</head>
|
||||
<body>
|
||||
hello <strong>{{name}},</strong>
|
||||
{{message}}
|
||||
</body>
|
||||
</html>
|
|
@ -0,0 +1,101 @@
|
|||
{% load static i18n %}
|
||||
|
||||
<form id="order_form" method="POST" action="{% url 'matrix:index' %}" data-toggle="validator" role="form">
|
||||
{% csrf_token %}
|
||||
<div class="title">
|
||||
<h3>{% trans "Matrix Chat hosting" %} </h3>
|
||||
</div>
|
||||
<div class="price">
|
||||
<span id="total"> {{ matrix_vm_pricing.name }}</span>
|
||||
<span>CHF/{% trans "month" %}</span>
|
||||
<div class="price-text">
|
||||
<p>
|
||||
{% if matrix_vm_pricing.set_up_fees %}{{ matrix_vm_pricing.set_up_fees }} CHF Setup<br>{% endif %}
|
||||
{% if matrix_vm_pricing.vat_inclusive %}{% trans "VAT included" %} <br>{% endif %}
|
||||
{% if matrix_vm_pricing.discount_amount %}
|
||||
{% trans "You save" %} {{ matrix_vm_pricing.discount_amount }} CHF
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="descriptions">
|
||||
<div class="description form-group">
|
||||
<p>{% trans "Hosted in Switzerland" %}</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="description input">
|
||||
<i class="fa fa-minus-circle left" data-minus="cores" aria-hidden="true"></i>
|
||||
<input class="input-price select-number" type="number" min="1" max="48" id="cores" step="1" name="cores"
|
||||
{% if form.cores.value != None %}value="{{ form.cores.value }}"{% endif %} data-error="{% trans 'Please enter a value in range 1 - 48.' %}" required>
|
||||
<span> Core</span>
|
||||
<i class="fa fa-plus-circle right" data-plus="cores" aria-hidden="true"></i>
|
||||
</div>
|
||||
<div class="help-block with-errors">
|
||||
{% for message in messages %}
|
||||
{% if 'cores' in message.tags %}
|
||||
<ul class="list-unstyled">
|
||||
<li>{{ message|safe }}</li>
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="description input">
|
||||
<i class="fa fa-minus-circle left" data-minus="memory" aria-hidden="true"></i>
|
||||
<input id="memory" class="input-price select-number" type="number" min="2" max="200" name="memory"
|
||||
{% if form.memory.value != None %}value="{{ form.memory.value }}"{% endif %} data-error="{% blocktrans with min_ram=min_ram %}Please enter a value in range {{min_ram}} - 200.{% endblocktrans %}" required step="1">
|
||||
<span> GB RAM</span>
|
||||
<i class="fa fa-plus-circle right" data-plus="memory" aria-hidden="true"></i>
|
||||
</div>
|
||||
<div class="help-block with-errors">
|
||||
{% for message in messages %}
|
||||
{% if 'memory' in message.tags %}
|
||||
<ul class="list-unstyled"><li>
|
||||
{{ message|safe }}
|
||||
</li></ul>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="description input">
|
||||
<i class="fa fa-minus-circle left" data-minus="storage" aria-hidden="true"></i>
|
||||
<input id="storage" class="input-price select-number" type="number" min="100" max="10000" step="100"
|
||||
name="storage" {% if form.storage.value != None %}value="{{ form.storage.value }}"{% endif %} data-error="{% trans 'Please enter a value in range 100 - 10000.' %}" required>
|
||||
<span>{% trans "GB Storage (SSD)" %}</span>
|
||||
<i class="fa fa-plus-circle right" data-plus="storage" aria-hidden="true"></i>
|
||||
</div>
|
||||
<div class="help-block with-errors">
|
||||
{% for message in messages %}
|
||||
{% if 'storage' in message.tags %}
|
||||
<ul class="list-unstyled"><li>
|
||||
{{ message|safe }}
|
||||
</li></ul>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="description domain select-configuration input form-group justify-center">
|
||||
<input type="text" id="matrix_domain" name="matrix_domain" placeholder="Matrix Domain" {% if form.matrix_domain.value != None %}value="{{ form.matrix_domain.value }}"{% endif %}></input>
|
||||
<p class="text-danger">{{ form.matrix_domain.errors }}</p>
|
||||
</div>
|
||||
<div class="description domain select-configuration input form-group justify-center">
|
||||
<input type="text" id="homeserver_domain" name="homeserver_domain" placeholder="Homeserver Domain" {% if form.homeserver_domain.value != None %}value="{{ form.homeserver_domain.value }}"{% endif %} ></input>
|
||||
<p class="text-danger">{{ form.homeserver_domain.errors }}</p>
|
||||
</div>
|
||||
<div class="description domain select-configuration input form-group justify-center">
|
||||
<input type="text" id="webclient_domain" name="webclient_domain" placeholder="Webclient Domain" {% if form.webclient_domain.value != None %}value="{{ form.webclient_domain.value }}"{% endif %}></input>
|
||||
<p class="text-danger">{{ form.webclient_domain.errors }}</p>
|
||||
</div>
|
||||
<div class="description input form-group">
|
||||
<div class="fieldWrapper">
|
||||
<span>Is open registration possible:</span>
|
||||
{{ form.is_open_registration }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<input type="hidden" name="pricing_name" id="pricing_name" value="{% if matrix_vm_pricing.name %}{{matrix_vm_pricing.name}}{% else %}unknown{% endif%}"></input>
|
||||
<input type="submit" class="btn btn-primary" value="{% trans 'Continue' %}"></input>
|
||||
</form>
|
43
matrixhosting/templates/matrixhosting/includes/_card.html
Normal file
43
matrixhosting/templates/matrixhosting/includes/_card.html
Normal file
|
@ -0,0 +1,43 @@
|
|||
{% load i18n %}
|
||||
<form action="" id="payment-form-new" method="POST">
|
||||
<input type="hidden" name="token"/>
|
||||
<input type="hidden" name="id_card" id="id_card" value=""/>
|
||||
<div class="group">
|
||||
<div class="credit-card-goup">
|
||||
<div class="card-element card-number-element">
|
||||
<label>{%trans "Card Number" %}</label>
|
||||
<div id="card-number-element" class="field my-input"></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-xs-5 card-element card-expiry-element">
|
||||
<label>{%trans "Expiry Date" %}</label>
|
||||
<div id="card-expiry-element" class="field my-input"></div>
|
||||
</div>
|
||||
<div class="col-xs-3 col-xs-offset-4 card-element card-cvc-element">
|
||||
<label>{%trans "CVC" %}</label>
|
||||
<div id="card-cvc-element" class="field my-input"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-element brand">
|
||||
<label>{%trans "Card Type" %}</label>
|
||||
<i class="pf pf-credit-card" id="brand-icon"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="card-errors"></div>
|
||||
<div id='payment_error'>
|
||||
{% for message in messages %}
|
||||
{% if 'failed_payment' in message.tags or 'make_charge_error' in message.tags or 'error' in message.tags %}
|
||||
<ul class="list-unstyled">
|
||||
<li><p class="card-warning-content card-warning-error">{{ message|safe }}</p></li>
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<button class="btn btn-vm-contact btn-wide" type="submit" name="payment-form">{%trans "SUBMIT" %}</button>
|
||||
</div>
|
||||
<div style="display:none;">
|
||||
<p class="payment-errors"></p>
|
||||
</div>
|
||||
</form>
|
18
matrixhosting/templates/matrixhosting/includes/_footer.html
Normal file
18
matrixhosting/templates/matrixhosting/includes/_footer.html
Normal file
|
@ -0,0 +1,18 @@
|
|||
{% load i18n %}
|
||||
<footer>
|
||||
<div class="container">
|
||||
<ul class="list-inline">
|
||||
<li>
|
||||
<a class="url-init" href="">{% trans "Home" %}</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="url-init" href="">{% trans "Contact" %}</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="url-init" href="">{% trans "Terms of Service" %}</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p class="copyright text-muted small">Copyright © ungleich glarus ag {% now "Y" %}. {% trans "All Rights Reserved" %}</p>
|
||||
</div>
|
||||
</footer>
|
33
matrixhosting/templates/matrixhosting/includes/_navbar.html
Normal file
33
matrixhosting/templates/matrixhosting/includes/_navbar.html
Normal file
|
@ -0,0 +1,33 @@
|
|||
{% load static i18n %}
|
||||
{% get_current_language as LANGUAGE_CODE %}
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="{% url 'uncloudindex' %}">Matrix Hosting</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNavDropdown" aria-controls="navbarNavDropdown" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNavDropdown">
|
||||
<ul class="navbar-nav ms-auto">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link active" aria-current="page" href="{% url 'matrix:index' %}">Home</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="#">Features</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="#">Pricing</a>
|
||||
</li>
|
||||
{% if not request.user.is_authenticated %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'account_login' %}">{% trans "Login" %} <i class="fa fa-sign-in-alt"></i></a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'matrix:dashboard' %}">{% trans "Dashboard" %}</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
21
matrixhosting/templates/matrixhosting/index.html
Normal file
21
matrixhosting/templates/matrixhosting/index.html
Normal file
|
@ -0,0 +1,21 @@
|
|||
{% extends "matrixhosting/base.html" %}
|
||||
{% load static i18n %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Page Content -->
|
||||
|
||||
<div class="split-section pricing-section section-gradient" id="price">
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="price-calc-section">
|
||||
<div class="card">
|
||||
{% include "matrixhosting/includes/_calculator_form.html" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- /.banner -->
|
||||
{% endblock %}
|
268
matrixhosting/templates/matrixhosting/order_detail.html
Normal file
268
matrixhosting/templates/matrixhosting/order_detail.html
Normal file
|
@ -0,0 +1,268 @@
|
|||
{% load static i18n %}
|
||||
{% load bootstrap5 %}
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="description" content="Matrix Hosting by ungleich">
|
||||
<meta name="author" content="ungleich glarus ag">
|
||||
<title>Matrix Hosting - {% block title %} made in Switzerland{% endblock %}</title>
|
||||
|
||||
<!-- Vendor CSS -->
|
||||
<!-- Bootstrap Core CSS -->
|
||||
{% bootstrap_css %}
|
||||
|
||||
<!-- External Fonts -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/paymentfont/1.2.5/css/paymentfont.min.css"/>
|
||||
<link href="//fonts.googleapis.com/css?family=Lato:300,400,600,700" rel="stylesheet" type="text/css">
|
||||
|
||||
<link href="{% static 'matrixhosting/css/hosting.css' %}" rel="stylesheet">
|
||||
|
||||
<!-- HTML5 Shim and Respond.js IE8 support of HTML5 elements and media queries -->
|
||||
<!-- WARNING: Respond.js doesn't work if you view the page via file:// -->
|
||||
<!--[if lt IE 9]>
|
||||
<script src="https://oss.maxcdn.com/libs/html5shiv/3.7.0/html5shiv.js"></script>
|
||||
<script src="https://oss.maxcdn.com/libs/respond.js/1.4.2/respond.min.js"></script>
|
||||
<![endif]-->
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<script>
|
||||
window.paymentIntentSecret = "{{payment_intent_secret}}";
|
||||
</script>
|
||||
<div id="order-detail{{order.pk}}" class="order-detail-container">
|
||||
{% if messages %}
|
||||
<div class="alert alert-warning">
|
||||
{% for message in messages %}
|
||||
<span>{{ message }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if not error %}
|
||||
<div class="dashboard-container-head">
|
||||
<h1 class="dashboard-title-thin">
|
||||
{% blocktrans with page_header_text=page_header_text|default:"Order" %}{{page_header_text}}{% endblocktrans %}
|
||||
</h1>
|
||||
</div>
|
||||
<div class="order-details">
|
||||
<hr>
|
||||
<div>
|
||||
<address>
|
||||
<h4>{% trans "Billed to" %}:</h4>
|
||||
<p>
|
||||
{% with request.session.billing_address_data as billing_address %}
|
||||
{{billing_address.full_name}}<br>
|
||||
{{billing_address.street}}, {{billing_address.postal_code}}<br>
|
||||
{{billing_address.city}}, {{billing_address.country}}
|
||||
{% if billing_address.vat_number %}
|
||||
<br/>{% trans "VAT Number" %} {{billing_address.vat_number}}
|
||||
{% if pricing.vat_country != "ch" and pricing.vat_validation_status != "not_needed" %}
|
||||
{% if pricing.vat_validation_status == "verified" %}
|
||||
<span class="fa fa-fw fa-check-circle" aria-hidden="true" title='{% trans "Your VAT number has been verified" %}'></span>
|
||||
{% else %}
|
||||
<span class="fa fa-fw fa-info-circle" aria-hidden="true" title='{% trans "Your VAT number is under validation. VAT will be adjusted, once the validation is complete." %}'></span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</p>
|
||||
</address>
|
||||
</div>
|
||||
<hr>
|
||||
<div>
|
||||
<h4>{% trans "Payment method" %}:</h4>
|
||||
<p>
|
||||
{{card.brand|default:_('Credit Card')}} {% trans "ending in" %} ****{{card.last4}}<br>
|
||||
{% trans "Expiry" %} {{card.exp_year}}/{{card.exp_month}}<br/>
|
||||
{{request.user.email}}
|
||||
</p>
|
||||
</div>
|
||||
<hr>
|
||||
<div>
|
||||
<h4>{% trans "Order summary" %}</h4>
|
||||
<style>
|
||||
@media screen and (max-width:400px){
|
||||
.header-no-left-padding {
|
||||
padding-left: 0 !important;
|
||||
}
|
||||
}
|
||||
@media screen and (max-width:767px){
|
||||
.cmf-ord-heading {
|
||||
font-size: 11px;
|
||||
}
|
||||
.order-detail-container .order-details {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width:367px){
|
||||
.cmf-ord-heading {
|
||||
font-size: 11px;
|
||||
}
|
||||
.order-detail-container .order-details {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
<p>
|
||||
<strong>{% trans "Product" %}:</strong>
|
||||
Matrix Chat Hosting
|
||||
</p>
|
||||
<div class="row">
|
||||
<div class="col-sm-9">
|
||||
<p>
|
||||
<span>{% trans "Cores" %}: </span>
|
||||
<strong class="pull-right">{{order.cores}}</strong>
|
||||
</p>
|
||||
<p>
|
||||
<span>{% trans "Memory" %}: </span>
|
||||
<strong class="pull-right">{{order.memory}} GB</strong>
|
||||
</p>
|
||||
<p>
|
||||
<span>{% trans "Disk space" %}: </span>
|
||||
<strong class="pull-right">{{order.storage}} GB</strong>
|
||||
</p>
|
||||
</div>
|
||||
<div class="col-sm-12">
|
||||
<hr class="thin-hr">
|
||||
</div>
|
||||
<div class="col-sm-9">
|
||||
<p>
|
||||
<strong class="text-uppercase">{% trans "Price Before VAT" %}</strong>
|
||||
<strong class="pull-right">{{pricing.subtotal|floatformat:2}} CHF</strong>
|
||||
</p>
|
||||
</div>
|
||||
<div class="col-sm-12">
|
||||
<hr class="thin-hr">
|
||||
</div>
|
||||
<div class="col-sm-9">
|
||||
<div class="row">
|
||||
<div class="col-md-4 col-sm-4 col-xs-4">
|
||||
<p><span></span></p>
|
||||
</div>
|
||||
<div class="col-md-3 col-sm-3 col-xs-4">
|
||||
<p class="text-right"><strong class="cmf-ord-heading">{% trans "Pre VAT" %}</strong></p>
|
||||
</div>
|
||||
<div class="col-md-5 col-sm-5 col-xs-4 header-no-left-padding">
|
||||
<p class="text-right"><strong class="cmf-ord-heading">{% trans "With VAT for" %} {{pricing.vat_country}} ({{pricing.vat_percent}}%)</strong></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-4 col-sm-4 col-xs-4">
|
||||
<p><span>Subtotal</span></p>
|
||||
</div>
|
||||
<div class="col-md-3 col-sm-3 col-xs-4">
|
||||
<p><span class="pull-right" >{{pricing.subtotal|floatformat:2}} CHF</span></p>
|
||||
</div>
|
||||
<div class="col-md-5 col-sm-5 col-xs-4">
|
||||
<p><span class="pull-right">{{pricing.price_with_vat|floatformat:2}} CHF</span></p>
|
||||
</div>
|
||||
</div>
|
||||
{% if pricing.discount.amount > 0 %}
|
||||
<div class="row">
|
||||
<div class="col-md-4 col-sm-4 col-xs-4">
|
||||
<p><span>{{pricing.discount.name}}</span></p>
|
||||
</div>
|
||||
<div class="col-md-3 col-sm-3 col-xs-4">
|
||||
<p><span class="pull-right">-{{pricing.discount.amount|floatformat:2}} CHF</span></p>
|
||||
</div>
|
||||
<div class="col-md-5 col-sm-5 col-xs-4">
|
||||
<p><span class="pull-right">-{{pricing.discount.amount_with_vat|floatformat:2}} CHF</span></p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-sm-12">
|
||||
<hr class="thin-hr">
|
||||
</div>
|
||||
<div class="col-sm-9">
|
||||
<div class="row">
|
||||
<div class="col-md-4 col-sm-4 col-xs-4">
|
||||
<p><strong>Total</strong></p>
|
||||
</div>
|
||||
<div class="col-md-3 col-sm-3 col-xs-4">
|
||||
<p><strong class="pull-right">{{pricing.subtotal_after_discount|floatformat:2}} CHF</strong></p>
|
||||
</div>
|
||||
<div class="col-md-5 col-sm-5 col-xs-4">
|
||||
<p><strong class="pull-right">{{pricing.price_after_discount_with_vat|floatformat:2}} CHF</strong></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-12">
|
||||
<hr class="thin-hr">
|
||||
</div>
|
||||
<div class="col-sm-9">
|
||||
<strong class="text-uppercase align-center">{% trans "Your Price in Total" %}</strong>
|
||||
<strong class="total-price pull-right">{{pricing.total_price|floatformat:2}} CHF</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr class="thin-hr">
|
||||
</div>
|
||||
<form id="virtual_machine_create_form" action="{% url 'matrix:order_details' %}" method="POST">
|
||||
{% csrf_token %}
|
||||
<div class="row">
|
||||
<div class="col-sm-8">
|
||||
<div class="dcl-place-order-text">{% blocktrans with vm_total_price=vm.total_price|floatformat:2 %}By clicking "Place order" you agree to our <a href="">Terms of Service</a> and this plan will charge your credit card account with {{ vm_total_price }} CHF/month{% endblocktrans %}.</div>
|
||||
</div>
|
||||
<div class="col-sm-4 order-confirm-btn text-right">
|
||||
<button class="btn choice-btn" id="btn-create-vm" data-bs-toggle="modal" data-bs-target="#createvm-modal">
|
||||
{% trans "Place order" %}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Create VM Modal -->
|
||||
<div class="modal fade" id="createvm-modal" tabindex="-1" role="dialog"
|
||||
aria-hidden="true" data-backdrop="static" data-keyboard="false">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="modal-icon">
|
||||
<i class="fa fa-cog fa-spin fa-3x fa-fw"></i>
|
||||
<span class="sr-only">{% trans "Processing..." %}</span>
|
||||
</div>
|
||||
<h4 class="modal-title" id="createvm-modal-title"></h4>
|
||||
<div class="modal-text" id="createvm-modal-body">
|
||||
{% trans "Hold tight, we are processing your request" %}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<a id="createvm-modal-done-btn" class="btn btn-success btn-ok btn-wide visually-hidden" href="">{% trans "OK" %}</a>
|
||||
<button id="createvm-modal-close-btn" type="button" class="btn btn-danger btn-ok btn-wide visually-hidden" data-dismiss="modal" aria-label="create-vm-close">{% trans "Close" %}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- / Create VM Modal -->
|
||||
|
||||
<script type="text/javascript">
|
||||
var create_vm_error_message = 'Some problem encountered. Please try again later';
|
||||
var pm_id = '{{id_payment_method}}';
|
||||
var error_url = '{{ error_msg.redirect }}';
|
||||
var success_url = '{{ success_msg.redirect }}';
|
||||
window.stripeKey = "{{stripe_key}}";
|
||||
window.isSubscription = ("{{is_subscription}}" === 'true');
|
||||
</script>
|
||||
<!-- jQuery -->
|
||||
<script src="https://js.stripe.com/v3/"></script>
|
||||
<script src="https://code.jquery.com/jquery-3.6.0.min.js" integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4=" crossorigin="anonymous"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery-validate/1.19.3/jquery.validate.min.js"></script>
|
||||
|
||||
<script src="{% static 'fontawesome_free/js/all.min.js' %}"></script>
|
||||
<!-- Bootstrap Core JavaScript -->
|
||||
{% bootstrap_javascript %}
|
||||
<!-- Custom JS -->
|
||||
<script type="text/javascript" src="{% static 'matrixhosting/js/order.js' %}"></script>
|
||||
</body>
|
||||
</html>
|
169
matrixhosting/templates/matrixhosting/payment.html
Normal file
169
matrixhosting/templates/matrixhosting/payment.html
Normal file
|
@ -0,0 +1,169 @@
|
|||
{% load static i18n %}
|
||||
{% load bootstrap5 %}
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="description" content="Matrix Hosting by ungleich">
|
||||
<meta name="author" content="ungleich glarus ag">
|
||||
<title>Matrix Hosting - {% block title %} made in Switzerland{% endblock %}</title>
|
||||
|
||||
<!-- Vendor CSS -->
|
||||
<!-- Bootstrap Core CSS -->
|
||||
{% bootstrap_css %}
|
||||
|
||||
<!-- External Fonts -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/paymentfont/1.2.5/css/paymentfont.min.css"/>
|
||||
<link href="//fonts.googleapis.com/css?family=Lato:300,400,600,700" rel="stylesheet" type="text/css">
|
||||
|
||||
<link href="{% static 'matrixhosting/css/hosting.css' %}" rel="stylesheet">
|
||||
|
||||
<!-- HTML5 Shim and Respond.js IE8 support of HTML5 elements and media queries -->
|
||||
<!-- WARNING: Respond.js doesn't work if you view the page via file:// -->
|
||||
<!--[if lt IE 9]>
|
||||
<script src="https://oss.maxcdn.com/libs/html5shiv/3.7.0/html5shiv.js"></script>
|
||||
<script src="https://oss.maxcdn.com/libs/respond.js/1.4.2/respond.min.js"></script>
|
||||
<![endif]-->
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<div class="row">
|
||||
<div class="dcl-payment-section">
|
||||
<h3>{%trans "Your Order" %}</h3>
|
||||
<hr class="top-hr">
|
||||
<div class="dcl-payment-order">
|
||||
<p>{% trans "Cores"%} <strong class="float-end">{{request.session.order.cores|floatformat}}</strong></p>
|
||||
<hr>
|
||||
<p>{% trans "Memory"%} <strong class="float-end">{{request.session.order.memory|floatformat}} GB</strong></p>
|
||||
<hr>
|
||||
<p>{% trans "Disk space"%} <strong class="float-end">{{request.session.order.storage|floatformat}} GB</strong></p>
|
||||
<hr>
|
||||
<p>
|
||||
<strong>{%trans "Total" %}</strong>
|
||||
<small>
|
||||
({% if matrix_vm_pricing.vat_inclusive %}{%trans "including VAT" %}{% else %}{%trans "excluding VAT" %}{% endif %})
|
||||
</small>
|
||||
<strong class="float-end">{{request.session.order.subtotal|floatformat}} CHF / {% trans "Month" %}</strong>
|
||||
</p>
|
||||
<hr>
|
||||
{% if matrix_vm_pricing.discount_amount %}
|
||||
<p class="mb-0">
|
||||
<strong>{{ request.session.order.discount.name }}</strong>
|
||||
<strong class="float-end text-success">- {{ request.session.order.discount.amount }} CHF / {% trans "Month" %}</strong>
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="dcl-payment-section">
|
||||
<h2><b>{%trans "Billing Address"%}</b></h2>
|
||||
<hr class="top-hr">
|
||||
{% for message in messages %}
|
||||
{% if 'vat_error' in message.tags %}
|
||||
<ul class="list-unstyled"><li>
|
||||
{{ message|safe }}
|
||||
</li></ul>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<form role="form" id="billing-form" method="post" action="" novalidate>
|
||||
{% csrf_token %}
|
||||
{% for field in billing_address_form %}
|
||||
{% if field.html_name in 'active,owner' %}
|
||||
{{ field.as_hidden }}
|
||||
{%else %}
|
||||
{% bootstrap_field field show_label=False type='fields'%}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="dcl-payment-section">
|
||||
{% with cards_len=cards|length %}
|
||||
<h3><b>{%trans "Credit Card"%}</b></h3>
|
||||
<hr class="top-hr">
|
||||
<p>
|
||||
{% if cards_len > 0 %}
|
||||
{% blocktrans %}Please select one of the cards that you used before or fill in your credit card information below. We are using <a href="https://stripe.com" target="_blank">Stripe</a> for payment and do not store your information in our database.{% endblocktrans %}
|
||||
{% else %}
|
||||
{% blocktrans %}Please fill in your credit card information below. We are using <a href="https://stripe.com" target="_blank">Stripe</a> for payment and do not store your information in our database.{% endblocktrans %}
|
||||
{% endif %}
|
||||
</p>
|
||||
<div>
|
||||
{% for card in cards %}
|
||||
<div class="credit-card-info">
|
||||
<div class="col-xs-6 no-padding">
|
||||
<h5 class="billing-head">{% trans "Credit Card" %}</h5>
|
||||
<h5 class="membership-lead">{% trans "Last" %} 4: ***** {{card.last4}}</h5>
|
||||
<h5 class="membership-lead">{% trans "Type" %}: {{card.brand}}</h5>
|
||||
<h5 class="membership-lead">{% trans "Expiry" %}: {{card.month}}/{{card.year}}</h5>
|
||||
</div>
|
||||
<div class="col-xs-6 text-right align-bottom">
|
||||
<a class="btn choice-btn choice-btn-faded" href="#" data-id_card="{{card.id}}">{% trans "SELECT" %}</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% if cards_len > 0 %}
|
||||
<div class="new-card-head">
|
||||
<div class="row">
|
||||
<div class="col-xs-6">
|
||||
<h4>{% trans "Add a new credit card" %}</h4>
|
||||
</div>
|
||||
<div class="col-xs-6 text-right new-card-button-margin">
|
||||
<button data-bs-toggle="collapse" data-bs-target="#newcard" class="btn choice-btn">
|
||||
<span class="fa fa-plus"></span> {% trans "NEW CARD" %}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="newcard" class="collapse">
|
||||
<hr class="thick-hr">
|
||||
<div class="card-details-box">
|
||||
<h3>{%trans "New Credit Card" %}</h3>
|
||||
<hr>
|
||||
{% include "matrixhosting/includes/_card.html" %}
|
||||
</div>
|
||||
</div>
|
||||
{% else%}
|
||||
{% include "matrixhosting/includes/_card.html" %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endwith %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if stripe_key %}
|
||||
{% get_current_language as LANGUAGE_CODE %}
|
||||
<script type="text/javascript">
|
||||
window.processing_text = '{%trans "Processing" %}';
|
||||
window.enter_your_card_text = '{%trans "Enter your credit card number" %}';
|
||||
|
||||
(function () {
|
||||
window.stripeKey = "{{stripe_key}}";
|
||||
window.current_lan = "{{LANGUAGE_CODE}}";
|
||||
})();
|
||||
</script>
|
||||
{%endif%}
|
||||
|
||||
<!-- jQuery -->
|
||||
<script src="https://js.stripe.com/v3/"></script>
|
||||
<script src="https://code.jquery.com/jquery-3.6.0.min.js" integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4=" crossorigin="anonymous"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery-validate/1.19.3/jquery.validate.min.js"></script>
|
||||
|
||||
<script src="{% static 'fontawesome_free/js/all.min.js' %}"></script>
|
||||
<!-- Bootstrap Core JavaScript -->
|
||||
{% bootstrap_javascript %}
|
||||
<!-- Custom JS -->
|
||||
<script type="text/javascript" src="{% static 'matrixhosting/js/payment.js' %}"></script>
|
||||
</body>
|
||||
</html>
|
67
matrixhosting/tests.py
Normal file
67
matrixhosting/tests.py
Normal file
|
@ -0,0 +1,67 @@
|
|||
import datetime
|
||||
import json
|
||||
|
||||
from django.test import TestCase
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.utils import timezone
|
||||
|
||||
from .models import VMInstance
|
||||
from uncloud_pay.models import Order, PricingPlan, BillingAddress, Product, RecurringPeriod
|
||||
|
||||
|
||||
vm_product_config = {
|
||||
'features': {
|
||||
'cores':
|
||||
{ 'min': 1,
|
||||
'max': 48
|
||||
},
|
||||
'ram_gb':
|
||||
{ 'min': 2,
|
||||
'max': 200
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
class VMInstanceTestCase(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
RecurringPeriod.populate_db_defaults()
|
||||
self.user = get_user_model().objects.create(
|
||||
username='random_user',
|
||||
email='jane.random@domain.tld')
|
||||
self.config = json.dumps({
|
||||
'cores': 1,
|
||||
'memory': 2,
|
||||
'storage': 100,
|
||||
'homeserver_domain': '',
|
||||
'webclient_domain': '',
|
||||
'matrix_domain': '',
|
||||
})
|
||||
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.ba = BillingAddress.objects.create(
|
||||
owner=self.user,
|
||||
organization = 'Test org',
|
||||
street="unknown",
|
||||
city="unknown",
|
||||
postal_code="somewhere else",
|
||||
active=True)
|
||||
|
||||
self.product = Product.objects.create(name="Testproduct",
|
||||
description="Only for testing",
|
||||
config=vm_product_config)
|
||||
self.default_recurring_period = RecurringPeriod.objects.get(name="Per 30 days")
|
||||
self.product.recurring_periods.add(self.default_recurring_period,
|
||||
through_defaults= { 'is_default': True })
|
||||
|
||||
def test_create_matrix_vm(self):
|
||||
order = Order.objects.create(owner=self.user,
|
||||
recurring_period=self.default_recurring_period,
|
||||
billing_address=self.ba,
|
||||
pricing_plan = self.pricing_plan,
|
||||
product=self.product,
|
||||
config=self.config)
|
||||
instances = VMInstance.objects.filter(order=order)
|
||||
self.assertEqual(len(instances), 1)
|
||||
|
||||
|
15
matrixhosting/urls.py
Normal file
15
matrixhosting/urls.py
Normal file
|
@ -0,0 +1,15 @@
|
|||
from django.urls import path, include
|
||||
from django.conf import settings
|
||||
from django.conf.urls.static import static
|
||||
|
||||
from .views import IndexView, PricingView, OrderPaymentView, OrderDetailsView, Dashboard
|
||||
|
||||
app_name = 'matrixhosting'
|
||||
|
||||
urlpatterns = [
|
||||
path('pricing/<slug:name>/calculate/', PricingView.as_view(), name='pricing_calculator'),
|
||||
path('payment/', OrderPaymentView.as_view(), name='payment'),
|
||||
path('order/details/', OrderDetailsView.as_view(), name='order_details'),
|
||||
path('dashboard/', Dashboard.as_view(), name='dashboard'),
|
||||
path('', IndexView.as_view(), name='index'),
|
||||
]
|
34
matrixhosting/validators.py
Normal file
34
matrixhosting/validators.py
Normal file
|
@ -0,0 +1,34 @@
|
|||
from django.core.validators import RegexValidator
|
||||
|
||||
|
||||
def _validator():
|
||||
|
||||
ul = '\u00a1-\uffff' # unicode letters range (must not be a raw string)
|
||||
|
||||
# IP patterns
|
||||
ipv4_re = r'(?:25[0-5]|2[0-4]\d|[0-1]?\d?\d)(?:\.(?:25[0-5]|2[0-4]\d|[0-1]?\d?\d)){3}'
|
||||
ipv6_re = r'\[[0-9a-f:\.]+\]' # (simple regex, validated later)
|
||||
|
||||
# Host patterns
|
||||
hostname_re = r'[a-z' + ul + \
|
||||
r'0-9](?:[a-z' + ul + r'0-9-]{0,61}[a-z' + ul + r'0-9])?'
|
||||
# Max length for domain name labels is 63 characters per RFC 1034 sec. 3.1
|
||||
domain_re = r'(?:\.(?!-)[a-z' + ul + r'0-9-]{1,63}(?<!-))*'
|
||||
tld_re = (
|
||||
r'\.' # dot
|
||||
r'(?!-)' # can't start with a dash
|
||||
r'(?:[a-z' + ul + '-]{2,63}' # domain label
|
||||
r'|xn--[a-z0-9]{1,59})' # or punycode label
|
||||
r'(?<!-)' # can't end with a dash
|
||||
r'\.?' # may have a trailing dot
|
||||
r'/?'
|
||||
)
|
||||
host_re = '(' + hostname_re + domain_re + tld_re + ')'
|
||||
regex = (
|
||||
r'(?:' + ipv4_re + '|' + ipv6_re + '|' + host_re + ')'
|
||||
r'(?::\d{2,5})?' # port
|
||||
r'\Z')
|
||||
return RegexValidator(regex, message='Enter a valid Domain (Not a URL)', code='invalid_domain')
|
||||
|
||||
|
||||
domain_name_validator = _validator()
|
301
matrixhosting/views.py
Normal file
301
matrixhosting/views.py
Normal file
|
@ -0,0 +1,301 @@
|
|||
import logging
|
||||
import json
|
||||
|
||||
from django.shortcuts import redirect, render
|
||||
from django.contrib import messages
|
||||
from django.utils.translation import get_language, ugettext_lazy as _
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.views.decorators.cache import cache_control
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views import View
|
||||
from django.views.generic import FormView, DetailView
|
||||
from django.views.generic.list import ListView
|
||||
from matrixhosting.forms import RequestHostedVMForm, BillingAddressForm
|
||||
from django.urls import reverse
|
||||
from django.conf import settings
|
||||
from django.http import (
|
||||
HttpResponseRedirect, JsonResponse
|
||||
)
|
||||
from rest_framework import viewsets, permissions
|
||||
|
||||
from uncloud_pay.models import PricingPlan
|
||||
from uncloud_pay.utils import get_order_total_with_vat
|
||||
from uncloud_pay.models import *
|
||||
from uncloud_pay.utils import validate_vat_number
|
||||
from uncloud_pay.selectors import get_billing_address_for_user
|
||||
import uncloud_pay.stripe as uncloud_stripe
|
||||
from .models import VMInstance
|
||||
from .serializers import *
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class PricingView(View):
|
||||
def get(self, request, **args):
|
||||
subtotal, subtotal_after_discount, price_after_discount_with_vat, vat, vat_percent, discount = get_order_total_with_vat(
|
||||
request.GET.get('cores'),
|
||||
request.GET.get('memory'),
|
||||
request.GET.get('storage'),
|
||||
pricing_name = args['name']
|
||||
)
|
||||
return JsonResponse({'subtotal': subtotal})
|
||||
|
||||
class IndexView(FormView):
|
||||
template_name = "matrixhosting/index.html"
|
||||
form_class = RequestHostedVMForm
|
||||
success_url = "/matrixhosting#requestform"
|
||||
success_message = "Thank you, we will contact you as soon as possible"
|
||||
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context['matrix_vm_pricing'] = PricingPlan.get_default_pricing()
|
||||
return context
|
||||
|
||||
def form_valid(self, form):
|
||||
self.request.session['order'] = form.cleaned_data
|
||||
subtotal, subtotal_with_discount, total, vat, vat_percent, discount = get_order_total_with_vat(
|
||||
form.cleaned_data['cores'],
|
||||
form.cleaned_data['memory'],
|
||||
form.cleaned_data['storage'],
|
||||
form.cleaned_data['pricing_name'],
|
||||
False
|
||||
)
|
||||
self.request.session['pricing'] = {'name': form.cleaned_data['pricing_name'],
|
||||
'subtotal': subtotal, 'vat': vat,
|
||||
'vat_percent': vat_percent, 'discount': discount}
|
||||
return HttpResponseRedirect(reverse('matrix:payment'))
|
||||
|
||||
|
||||
class OrderPaymentView(FormView):
|
||||
template_name = 'matrixhosting/payment.html'
|
||||
success_url = 'matrix:order_confirmation'
|
||||
form_class = BillingAddressForm
|
||||
|
||||
@method_decorator(login_required)
|
||||
def dispatch(self, *args, **kwargs):
|
||||
return super().dispatch(*args, **kwargs)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super(OrderPaymentView, self).get_context_data(**kwargs)
|
||||
if 'billing_address_data' in self.request.session:
|
||||
billing_address_form = BillingAddressForm(
|
||||
initial=self.request.session['billing_address_data']
|
||||
)
|
||||
else:
|
||||
old_active = get_billing_address_for_user(self.request.user)
|
||||
billing_address_form = BillingAddressForm(
|
||||
instance=old_active
|
||||
) if old_active else BillingAddressForm(
|
||||
initial={'active': True, 'owner': self.request.user.id}
|
||||
)
|
||||
customer_id = uncloud_stripe.get_customer_id_for(self.request.user)
|
||||
cards = uncloud_stripe.get_customer_cards(customer_id)
|
||||
context.update({
|
||||
'matrix_vm_pricing': PricingPlan.get_by_name(self.request.session.get('pricing', {'name': 'unknown'})['name']),
|
||||
'billing_address_form': billing_address_form,
|
||||
'cards': cards,
|
||||
'stripe_key': settings.STRIPE_PUBLIC_KEY
|
||||
})
|
||||
|
||||
return context
|
||||
|
||||
@cache_control(no_cache=True, must_revalidate=True, no_store=True)
|
||||
def get(self, request, *args, **kwargs):
|
||||
for k in ['vat_validation_status', 'token', 'id_payment_method']:
|
||||
if request.session.get(k):
|
||||
request.session.pop(k)
|
||||
if 'order' not in request.session:
|
||||
return HttpResponseRedirect(reverse('matrix:index'))
|
||||
return self.render_to_response(self.get_context_data())
|
||||
|
||||
def form_valid(self, address_form):
|
||||
id_payment_method = self.request.POST.get('id_payment_method', None)
|
||||
self.request.session["id_payment_method"] = id_payment_method
|
||||
this_user = {
|
||||
'email': self.request.user.email,
|
||||
'username': self.request.user.username
|
||||
}
|
||||
customer_id = uncloud_stripe.get_customer_id_for(self.request.user)
|
||||
uncloud_stripe.attach_payment_method(id_payment_method, customer_id)
|
||||
address = get_billing_address_for_user(self.request.user)
|
||||
if address:
|
||||
form = BillingAddressForm(self.request.POST, instance=address)
|
||||
else:
|
||||
form = BillingAddressForm(self.request.POST)
|
||||
if form.is_valid:
|
||||
billing_address_ins = form.save()
|
||||
self.request.session["billing_address_id"] = billing_address_ins.id
|
||||
self.request.session['billing_address_data'] = address_form.cleaned_data
|
||||
self.request.session['billing_address_data']['owner'] = self.request.user.id
|
||||
self.request.session['user'] = this_user
|
||||
self.request.session['customer'] = customer_id
|
||||
|
||||
vat_number = address_form.cleaned_data.get('vat_number').strip()
|
||||
if vat_number:
|
||||
validate_result = validate_vat_number(
|
||||
stripe_customer_id=customer_id,
|
||||
billing_address_id=billing_address_ins.id
|
||||
)
|
||||
|
||||
if 'error' in validate_result and validate_result['error']:
|
||||
messages.add_message(
|
||||
self.request, messages.ERROR, validate_result["error"],
|
||||
extra_tags='vat_error'
|
||||
)
|
||||
return HttpResponseRedirect(
|
||||
reverse('matrix:payment') + '#vat_error'
|
||||
)
|
||||
self.request.session["vat_validation_status"] = validate_result["status"]
|
||||
|
||||
return HttpResponseRedirect(reverse('matrix:order_details'))
|
||||
|
||||
|
||||
class OrderDetailsView(DetailView):
|
||||
template_name = "matrixhosting/order_detail.html"
|
||||
context_object_name = "order"
|
||||
model = Order
|
||||
|
||||
@method_decorator(login_required)
|
||||
def dispatch(self, *args, **kwargs):
|
||||
return super().dispatch(*args, **kwargs)
|
||||
|
||||
@cache_control(no_cache=True, must_revalidate=True, no_store=True)
|
||||
def get(self, request, *args, **kwargs):
|
||||
context = {}
|
||||
if ('order' not in request.session or 'user' not in request.session):
|
||||
return HttpResponseRedirect(reverse('matrix:index'))
|
||||
if 'id_payment_method' in self.request.session:
|
||||
card = uncloud_stripe.get_card_from_payment(self.request.user, self.request.session['id_payment_method'])
|
||||
if not card:
|
||||
return HttpResponseRedirect(reverse('matrix:payment'))
|
||||
context['card'] = card
|
||||
elif 'id_payment_method' not in self.request.session or 'vat_validation_status' not in self.request.session:
|
||||
return HttpResponseRedirect(reverse('matrix:payment'))
|
||||
specs = request.session.get('order')
|
||||
pricing = request.session.get('pricing')
|
||||
billing_address = BillingAddress.objects.get(id=request.session.get('billing_address_id'))
|
||||
vat_rate = VATRate.get_vat_rate(billing_address)
|
||||
vat_validation_status = "verified" if billing_address.vat_number_validated_on and billing_address.vat_number_verified else False
|
||||
subtotal, subtotal_after_discount, price_after_discount_with_vat, vat, vat_percent, discount = get_order_total_with_vat(
|
||||
specs['cores'], specs['memory'], specs['storage'], request.session['pricing']['name'],
|
||||
vat_rate=vat_rate * 100, vat_validation_status = vat_validation_status
|
||||
)
|
||||
pricing = {
|
||||
"subtotal": subtotal, "discount": discount, "vat": vat, "vat_percent": vat_percent,
|
||||
"vat_country": billing_address.country.lower(),
|
||||
"subtotal_after_discount": subtotal_after_discount,
|
||||
"price_after_discount_with_vat": price_after_discount_with_vat
|
||||
|
||||
}
|
||||
pricing["price_with_vat"] = round(subtotal * (1 + pricing["vat_percent"] * 0.01), 2)
|
||||
discount["amount_with_vat"] = round(pricing["price_with_vat"] - pricing["price_after_discount_with_vat"], 2)
|
||||
pricing["total_price"] = pricing["price_after_discount_with_vat"]
|
||||
self.request.session['total_price'] = pricing["price_after_discount_with_vat"]
|
||||
payment_intent_response = uncloud_stripe.get_payment_intent(request.user, pricing["price_after_discount_with_vat"])
|
||||
context.update({
|
||||
'payment_intent_secret': payment_intent_response.client_secret,
|
||||
'order': specs,
|
||||
'pricing': pricing,
|
||||
'stripe_key': settings.STRIPE_PUBLIC_KEY,
|
||||
})
|
||||
return render(request, self.template_name, context)
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
customer = StripeCustomer.objects.get(owner=self.request.user)
|
||||
billing_address = BillingAddress.objects.get(id=request.session.get('billing_address_id'))
|
||||
if 'id_payment_method' in request.session:
|
||||
card = uncloud_stripe.get_card_from_payment(self.request.user, self.request.session['id_payment_method'])
|
||||
if not card:
|
||||
return show_error("There was a payment related error.", self.request)
|
||||
else:
|
||||
return show_error("There was a payment related error.", self.request)
|
||||
|
||||
order = finalize_order(request, customer,
|
||||
billing_address,
|
||||
self.request.session['total_price'],
|
||||
PricingPlan.get_by_name(self.request.session['pricing']['name']),
|
||||
request.session.get('order'))
|
||||
if order:
|
||||
bill = Bill.create_next_bill_for_user_address(billing_address)
|
||||
payment= Payment.objects.create(owner=request.user, amount=self.request.session['total_price'], source='stripe')
|
||||
if payment:
|
||||
#Close the bill as the payment has been added
|
||||
bill.close()
|
||||
response = {
|
||||
'status': True,
|
||||
'redirect': (reverse('matrix:dashboard')),
|
||||
'msg_title': str(_('Thank you for the order.')),
|
||||
'msg_body': str(
|
||||
_('Your VM will be up and running in a few moments.'
|
||||
' We will send you a confirmation email as soon as'
|
||||
' it is ready.'))
|
||||
}
|
||||
|
||||
return JsonResponse(response)
|
||||
|
||||
|
||||
def finalize_order(request, customer, billing_address,
|
||||
one_time_price, pricing_plan,
|
||||
specs):
|
||||
product = Product.objects.first()
|
||||
recurring_period_product = ProductToRecurringPeriod.objects.filter(product=product, is_default=True).first()
|
||||
order = Order.objects.create(
|
||||
owner=request.user,
|
||||
customer=customer,
|
||||
billing_address=billing_address,
|
||||
one_time_price=one_time_price,
|
||||
pricing_plan=pricing_plan,
|
||||
recurring_period= recurring_period_product.recurring_period,
|
||||
product = product,
|
||||
config=json.dumps(specs)
|
||||
)
|
||||
return order
|
||||
|
||||
|
||||
class Dashboard(ListView):
|
||||
template_name = "matrixhosting/dashboard.html"
|
||||
model = Order
|
||||
|
||||
@method_decorator(login_required)
|
||||
def dispatch(self, *args, **kwargs):
|
||||
return super().dispatch(*args, **kwargs)
|
||||
|
||||
def get_queryset(self):
|
||||
return Order.objects.filter(owner=self.request.user)
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
order = Order.objects.get(id=request.POST.get('order_id', 0))
|
||||
order.cancel()
|
||||
return JsonResponse({'message': 'Successfully Cancelled'})
|
||||
|
||||
|
||||
def get_error_response_dict(request):
|
||||
response = {
|
||||
'status': False,
|
||||
'redirect': "{url}#{section}".format(
|
||||
url=(reverse('matrix:payment')),
|
||||
section='payment_error'
|
||||
),
|
||||
'msg_title': str(_('Error.')),
|
||||
'msg_body': str(
|
||||
_('There was a payment related error.'
|
||||
' On close of this popup, you will be redirected back to'
|
||||
' the payment page.'))
|
||||
}
|
||||
return response
|
||||
|
||||
|
||||
def show_error(msg, request):
|
||||
messages.add_message(request, messages.ERROR, msg,
|
||||
extra_tags='failed_payment')
|
||||
return JsonResponse(get_error_response_dict(request))
|
||||
|
||||
|
||||
class MachineViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
serializer_class = VMInstanceSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
def get_queryset(self):
|
||||
return VMInstance.objects.filter(owner=self.request.user)
|
||||
|
||||
|
15
matrixhosting/yaml/deployment.yaml.tmpl
Normal file
15
matrixhosting/yaml/deployment.yaml.tmpl
Normal file
|
@ -0,0 +1,15 @@
|
|||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ name }}-matrix
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
app: {{ name }}-matrix
|
||||
replicas: 1
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: {{ name }}-matrix
|
||||
use-as-service: {{ name }}
|
|
@ -1,16 +1,15 @@
|
|||
# Django basics
|
||||
django
|
||||
Django==3.2.4
|
||||
djangorestframework
|
||||
django-auth-ldap
|
||||
django-bootstrap-v5
|
||||
fontawesome-free
|
||||
|
||||
psycopg2
|
||||
ldap3
|
||||
|
||||
django-allauth
|
||||
xmltodict
|
||||
|
||||
parsedatetime
|
||||
|
||||
# Follow are for creating graph models
|
||||
pyparsing
|
||||
pydot
|
||||
|
@ -22,12 +21,14 @@ django-hardcopy
|
|||
# schema support
|
||||
pyyaml
|
||||
uritemplate
|
||||
|
||||
tldextract
|
||||
# Payment & VAT
|
||||
vat-validator
|
||||
stripe
|
||||
|
||||
|
||||
# Tasks
|
||||
celery
|
||||
#Jobs
|
||||
django-q
|
||||
redis
|
||||
|
||||
jinja2
|
||||
python-gitlab
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
from django.utils.translation import gettext_lazy as _
|
||||
import decimal
|
||||
from .celery import app as celery_app
|
||||
|
||||
# Define DecimalField properties, used to represent amounts of money.
|
||||
AMOUNT_MAX_DIGITS=10
|
||||
|
@ -251,4 +250,4 @@ COUNTRIES = (
|
|||
)
|
||||
|
||||
|
||||
__all__ = ('celery_app',)
|
||||
__all__ = ()
|
||||
|
|
|
@ -2,5 +2,5 @@ from django.contrib import admin
|
|||
|
||||
from .models import *
|
||||
|
||||
for m in [ UncloudProvider, UncloudNetwork, UncloudTask ]:
|
||||
for m in [ UncloudProvider, UncloudNetwork ]:
|
||||
admin.site.register(m)
|
||||
|
|
|
@ -1,17 +0,0 @@
|
|||
import os
|
||||
|
||||
from celery import Celery
|
||||
|
||||
# set the default Django settings module for the 'celery' program.
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'uncloud.settings')
|
||||
|
||||
app = Celery('uncloud')
|
||||
|
||||
# Using a string here means the worker doesn't have to serialize
|
||||
# the configuration object to child processes.
|
||||
# - namespace='CELERY' means all celery-related configuration keys
|
||||
# should have a `CELERY_` prefix.
|
||||
app.config_from_object('django.conf:settings', namespace='CELERY')
|
||||
|
||||
# Load task modules from all registered Django app configs.
|
||||
app.autodiscover_tasks()
|
8
uncloud/forms.py
Normal file
8
uncloud/forms.py
Normal file
|
@ -0,0 +1,8 @@
|
|||
from django import forms
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
|
||||
class UserDeleteForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = User
|
||||
fields = []
|
16
uncloud/migrations/0005_delete_uncloudtask.py
Normal file
16
uncloud/migrations/0005_delete_uncloudtask.py
Normal file
|
@ -0,0 +1,16 @@
|
|||
# Generated by Django 3.2.4 on 2021-07-07 15:11
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('uncloud', '0004_auto_20210101_1308'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.DeleteModel(
|
||||
name='UncloudTask',
|
||||
),
|
||||
]
|
|
@ -57,10 +57,10 @@ class CountryField(models.CharField):
|
|||
|
||||
|
||||
class UncloudAddress(models.Model):
|
||||
full_name = models.CharField(max_length=256)
|
||||
full_name = models.CharField(max_length=256, null=False)
|
||||
organization = models.CharField(max_length=256, blank=True, null=True)
|
||||
street = models.CharField(max_length=256)
|
||||
city = models.CharField(max_length=256)
|
||||
street = models.CharField(max_length=256, null=False)
|
||||
city = models.CharField(max_length=256, null=False)
|
||||
postal_code = models.CharField(max_length=64)
|
||||
country = CountryField(blank=False, null=False)
|
||||
|
||||
|
@ -207,18 +207,3 @@ class UncloudProvider(UncloudAddress):
|
|||
def __str__(self):
|
||||
return f"{self.full_name} {self.country}"
|
||||
|
||||
|
||||
class UncloudTask(models.Model):
|
||||
"""
|
||||
Class to store dispatched tasks to be handled
|
||||
"""
|
||||
|
||||
task_id = models.UUIDField(primary_key=True)
|
||||
|
||||
# class UncloudRequestLog(models.Model):
|
||||
# """
|
||||
# Class to store requests and logs
|
||||
# """
|
||||
|
||||
|
||||
# log = models.CharField(max_length=256)
|
||||
|
|
|
@ -13,6 +13,7 @@ https://docs.djangoproject.com/en/3.0/ref/settings/
|
|||
import os
|
||||
import re
|
||||
import ldap
|
||||
import sys
|
||||
|
||||
from django.core.management.utils import get_random_secret_key
|
||||
from django_auth_ldap.config import LDAPSearch, LDAPSearchUnion
|
||||
|
@ -23,6 +24,8 @@ LOGGING = {}
|
|||
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
sys.modules['fontawesome_free'] = __import__('fontawesome-free')
|
||||
|
||||
# https://docs.djangoproject.com/en/3.0/ref/settings/#databases
|
||||
DATABASES = {
|
||||
'default': {
|
||||
|
@ -38,7 +41,7 @@ DATABASES = {
|
|||
# SECURITY WARNING: don't run with debug turned on in production!
|
||||
DEBUG = True
|
||||
|
||||
|
||||
SITE_ID = 1
|
||||
|
||||
# Application definition
|
||||
|
||||
|
@ -48,10 +51,16 @@ INSTALLED_APPS = [
|
|||
'django.contrib.contenttypes',
|
||||
'django.contrib.sessions',
|
||||
'django.contrib.messages',
|
||||
'django.contrib.sites',
|
||||
'allauth',
|
||||
'allauth.account',
|
||||
'allauth.socialaccount',
|
||||
'django.contrib.staticfiles',
|
||||
'django_extensions',
|
||||
'rest_framework',
|
||||
'bootstrap5',
|
||||
'django_q',
|
||||
'fontawesome_free',
|
||||
'uncloud',
|
||||
'uncloud_pay',
|
||||
'uncloud_auth',
|
||||
|
@ -59,7 +68,8 @@ INSTALLED_APPS = [
|
|||
'uncloud_storage',
|
||||
'uncloud_vm',
|
||||
'uncloud_service',
|
||||
'opennebula'
|
||||
'opennebula',
|
||||
'matrixhosting',
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
|
@ -110,7 +120,13 @@ AUTH_PASSWORD_VALIDATORS = [
|
|||
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
|
||||
},
|
||||
]
|
||||
|
||||
###############################################################################
|
||||
# Authall Settings
|
||||
ACCOUNT_AUTHENTICATION_METHOD = "username"
|
||||
ACCOUNT_EMAIL_CONFIRMATION_EXPIRE_DAYS = 1
|
||||
ACCOUNT_EMAIL_REQUIRED = False
|
||||
ACCOUNT_EMAIL_VERIFICATION = "optional"
|
||||
ACCOUNT_UNIQUE_EMAIL = False
|
||||
################################################################################
|
||||
# AUTH/LDAP
|
||||
|
||||
|
@ -131,7 +147,8 @@ AUTH_LDAP_USER_ATTR_MAP = {
|
|||
# AUTH/Django
|
||||
AUTHENTICATION_BACKENDS = [
|
||||
"django_auth_ldap.backend.LDAPBackend",
|
||||
"django.contrib.auth.backends.ModelBackend"
|
||||
"django.contrib.auth.backends.ModelBackend",
|
||||
'allauth.account.auth_backends.AuthenticationBackend',
|
||||
]
|
||||
|
||||
AUTH_USER_MODEL = 'uncloud_auth.User'
|
||||
|
@ -170,6 +187,13 @@ STATICFILES_FINDERS = [
|
|||
'django.contrib.staticfiles.finders.AppDirectoriesFinder',
|
||||
]
|
||||
|
||||
#VM Deployment TEMPLATE
|
||||
GITLAB_SERVER = 'https://code.ungleich.ch'
|
||||
GITLAB_OAUTH_TOKEN = ''
|
||||
GITLAB_PROJECT_ID = 388
|
||||
GITLAB_AUTHOR_EMAIL = ''
|
||||
GITLAB_AUTHOR_NAME = ''
|
||||
GITLAB_YAML_DIR = ''
|
||||
|
||||
# XML-RPC interface of opennebula
|
||||
OPENNEBULA_URL = 'https://opennebula.example.com:2634/RPC2'
|
||||
|
@ -180,7 +204,7 @@ OPENNEBULA_USER_PASS = 'user:password'
|
|||
# Stripe (Credit Card payments)
|
||||
STRIPE_KEY=""
|
||||
STRIPE_PUBLIC_KEY=""
|
||||
|
||||
BILL_PAYMENT_DELAY = 0
|
||||
# The django secret key
|
||||
SECRET_KEY=get_random_secret_key()
|
||||
|
||||
|
@ -206,43 +230,34 @@ AUTH_LDAP_USER_SEARCH = LDAPSearch("dc=example,dc=com",
|
|||
# where to create customers
|
||||
LDAP_CUSTOMER_DN="ou=customer,dc=example,dc=com"
|
||||
|
||||
# def route_task(name, args, kwargs, options, task=None, **kw):
|
||||
# print(f"{name} - {args} - {kwargs}")
|
||||
# # if name == 'myapp.tasks.compress_video':
|
||||
# return {'queue': 'vpn1' }
|
||||
# # 'exchange_type': 'topic',
|
||||
# # 'routing_key': 'video.compress'}
|
||||
EMAIL_USE_TLS = True
|
||||
EMAIL_HOST = ''
|
||||
EMAIL_PORT = 465
|
||||
EMAIL_HOST_USER = DEFAULT_FROM_EMAIL = ''
|
||||
EMAIL_HOST_PASSWORD = ''
|
||||
DEFAULT_FROM_EMAIL = ''
|
||||
RENEWAL_FROM_EMAIL = 'test@example.com'
|
||||
# Should be removed in production
|
||||
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
|
||||
|
||||
|
||||
# CELERY_TASK_ROUTES = (route_task,)
|
||||
|
||||
# CELERY_TASK_ROUTES = {
|
||||
# '*': {
|
||||
# 'queue': 'vpn1'
|
||||
# }
|
||||
# }
|
||||
|
||||
|
||||
CELERY_BROKER_URL = 'redis://:uncloud.example.com:6379/0'
|
||||
CELERY_RESULT_BACKEND = 'redis://:uncloud.example.com:6379/0'
|
||||
|
||||
CELERY_TASK_ROUTES = {
|
||||
re.compile(r'.*.tasks.cdist.*'): { 'queue': 'cdist' } # cdist tasks go into cdist queue
|
||||
##############
|
||||
# Jobs
|
||||
Q_CLUSTER = {
|
||||
'name': 'matrixhosting',
|
||||
'workers': 1,
|
||||
'recycle': 500,
|
||||
'timeout': 60,
|
||||
'compress': True,
|
||||
'cpu_affinity': 1,
|
||||
'save_limit': 250,
|
||||
'queue_limit': 500,
|
||||
'label': 'Django Q',
|
||||
'redis': {
|
||||
'host': '127.0.0.1',
|
||||
'port': 6379,
|
||||
'db': 0, }
|
||||
}
|
||||
|
||||
CELERY_BEAT_SCHEDULE = {
|
||||
'cleanup_tasks': {
|
||||
'task': 'uncloud.tasks.cleanup_tasks',
|
||||
'schedule': 10
|
||||
},
|
||||
'check_balance': {
|
||||
'task': 'uncloud_pay.tasks.check_balance',
|
||||
'schedule': 15
|
||||
}
|
||||
}
|
||||
|
||||
# CELERY_TASK_CREATE_MISSING_QUEUES = False
|
||||
|
||||
# Overwrite settings with local settings, if existing
|
||||
try:
|
||||
from uncloud.local_settings import *
|
||||
|
|
|
@ -1,22 +0,0 @@
|
|||
from celery import shared_task
|
||||
from celery.result import AsyncResult
|
||||
|
||||
from .models import UncloudTask
|
||||
|
||||
@shared_task(bind=True)
|
||||
def cleanup_tasks(self):
|
||||
print(f"Cleanup time from {self}: {self.request.id}")
|
||||
for task in UncloudTask.objects.all():
|
||||
print(f"Pruning {task}...")
|
||||
|
||||
if str(task.task_id) == str(self.request.id):
|
||||
print("Skipping myself")
|
||||
continue
|
||||
|
||||
res = AsyncResult(id=str(task.task_id))
|
||||
print(f"Task {task}: {res.state}")
|
||||
if res.ready():
|
||||
print(res.get())
|
||||
task.delete()
|
||||
|
||||
res.forget()
|
|
@ -4,6 +4,7 @@
|
|||
<nav class="navbar sticky-top navbar-expand-lg navbar-light bg-light">
|
||||
<div class="container">
|
||||
<a class="navbar-brand" href="{% url 'uncloudindex' %}">uncloud</a>
|
||||
<a class="navbar-brand" href="{% url 'matrix:index' %}">Matrix Hosting</a>
|
||||
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
|
@ -14,11 +15,11 @@
|
|||
<span class="navbar-text">Logged in as {{ user }}. Your balance:
|
||||
{{ balance }} CHF. </span>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'logout' %}">Logout</a>
|
||||
<a class="nav-link" href="{% url 'account_logout' %}">Logout</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'login' %}">Login</a>
|
||||
<a class="nav-link" href="{% url 'account_login' %}">Login</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
|
|
|
@ -38,7 +38,7 @@
|
|||
<li>First you need
|
||||
to <a href="https://account.ungleich.ch">register an
|
||||
account</a>. If you already have one, you can
|
||||
<a href="{% url 'login' %}">login</a>.
|
||||
<a href="{% url 'account_login' %}">login</a>.
|
||||
<li>If you have forgotten your password or other issues with
|
||||
logging in, you can contact the ungleich support
|
||||
via <strong>support at ungleich.ch</strong>.
|
||||
|
@ -107,8 +107,11 @@
|
|||
<ul>
|
||||
<li><a href="{% url 'billingaddress-list' %}">Create or list
|
||||
your billing addresses</a>
|
||||
<li><a href="{% url 'orders-list' %}">List your Orders</a>
|
||||
<li><a href="{% url 'bills-list' %}">List your Bills</a>
|
||||
<li><a href="{% url 'payment-list' %}">Make a payment or list your payments</a>
|
||||
<li><a href="{% url 'payment-balance-list' %}">Show your balance</a>
|
||||
<li><a href="{% url 'machines-list' %}">Show your VM Instances</a>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -138,11 +141,30 @@
|
|||
<div class="col-8">
|
||||
<ul>
|
||||
<li>Payments are only possible in CHF.
|
||||
<li>Bills are not yet visible (payments are, though)
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if user.is_authenticated %}
|
||||
<div id="account-settings" class="row">
|
||||
<div class="col"><h3>Account Settings</h3></div>
|
||||
<div class="col-8">
|
||||
<ul>
|
||||
<form method="POST">
|
||||
{% csrf_token %}
|
||||
<fieldset class="form-group">
|
||||
<legend class="border-bottom mb-4">Delete User Account</legend>
|
||||
<p>Are you sure you want to delete your account? This will permanently delete your
|
||||
profile and any orders you have generated.</p>
|
||||
{{ delete_form }}
|
||||
</fieldset>
|
||||
<div class="form-group">
|
||||
<button class="btn btn-danger btn-lg" type="submit" name="action">Delete Account</button>
|
||||
</div>
|
||||
</form>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
|
@ -19,6 +19,7 @@ from uncloud_net import views as netviews
|
|||
from uncloud_pay import views as payviews
|
||||
from uncloud_vm import views as vmviews
|
||||
from uncloud_service import views as serviceviews
|
||||
from matrixhosting import views as matrixviews
|
||||
|
||||
router = routers.DefaultRouter()
|
||||
|
||||
|
@ -37,6 +38,9 @@ router.register(r'v2/payment/credit-card', payviews.CreditCardViewSet, basename=
|
|||
router.register(r'v2/payment/payment', payviews.PaymentViewSet, basename='payment')
|
||||
router.register(r'v2/payment/balance', payviews.BalanceViewSet, basename='payment-balance')
|
||||
router.register(r'v2/payment/address', payviews.BillingAddressViewSet, basename='billingaddress')
|
||||
router.register(r'v2/orders', payviews.OrderViewSet, basename='orders')
|
||||
router.register(r'v2/bill', payviews.BillViewSet, basename='bills')
|
||||
router.register(r'v2/machines', matrixviews.MachineViewSet, basename='machines')
|
||||
|
||||
# Generic helper views that are usually not needed
|
||||
router.register(r'v2/generic/vat-rate', payviews.VATRateViewSet, basename='vatrate')
|
||||
|
@ -54,9 +58,8 @@ urlpatterns = [
|
|||
|
||||
path('admin/', admin.site.urls),
|
||||
|
||||
path('login/', authviews.LoginView.as_view(), name="login"),
|
||||
path('logout/', authviews.LogoutView.as_view(), name="logout"),
|
||||
path('accounts/', include('allauth.urls')),
|
||||
path('cc/reg/', payviews.RegisterCard.as_view(), name="cc_register"),
|
||||
|
||||
path('matrix/', include('matrixhosting.urls', namespace='matrix')),
|
||||
path('', uncloudviews.UncloudIndex.as_view(), name="uncloudindex"),
|
||||
]
|
||||
|
|
|
@ -1,13 +1,23 @@
|
|||
from django.views.generic.base import TemplateView
|
||||
from django.contrib import messages
|
||||
from django.shortcuts import redirect
|
||||
|
||||
from uncloud_pay.selectors import get_balance_for_user
|
||||
from .forms import UserDeleteForm
|
||||
|
||||
class UncloudIndex(TemplateView):
|
||||
template_name = "uncloud/index.html"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
print(context)
|
||||
if self.request.user.is_authenticated:
|
||||
context['balance'] = get_balance_for_user(self.request.user)
|
||||
context['delete_form'] = UserDeleteForm(instance=self.request.user)
|
||||
return context
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
UserDeleteForm(request.POST, instance=request.user)
|
||||
user = request.user
|
||||
user.delete()
|
||||
messages.info(request, 'Your account has been deleted.')
|
||||
return redirect('uncloudindex')
|
|
@ -3,7 +3,7 @@ from django.db import transaction
|
|||
from .models import *
|
||||
from .selectors import *
|
||||
from .tasks import *
|
||||
|
||||
from django_q.tasks import async_task, result
|
||||
|
||||
@transaction.atomic
|
||||
def create_wireguard_vpn(owner, public_key, network_mask):
|
||||
|
@ -60,7 +60,6 @@ def create_wireguard_vpn_tech(owner, public_key, network_mask):
|
|||
server = pool.vpn_server_hostname
|
||||
wg_name = pool.wg_name
|
||||
|
||||
configure_wireguard_server_on_host.apply_async((wg_name, config),
|
||||
queue=server)
|
||||
async_task(configure_wireguard_server_on_host, (wg_name, config), queue=server)
|
||||
|
||||
return vpn
|
||||
|
|
|
@ -1,17 +1,14 @@
|
|||
from celery import shared_task
|
||||
from .models import *
|
||||
|
||||
from uncloud.models import UncloudTask
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from django_q.tasks import async_task, result
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@shared_task
|
||||
|
||||
def configure_wireguard_server_on_host(wg_name, config):
|
||||
"""
|
||||
- Create wireguard config (DB query -> string)
|
||||
|
@ -47,11 +44,9 @@ def configure_wireguard_server_via_cdist(wireguardvpnpool):
|
|||
|
||||
log.info(f"Configuring VPN server {server} (async)")
|
||||
|
||||
task_id = uuid.UUID(cdist_configure_wireguard_server.apply_async((config, server)).id)
|
||||
UncloudTask.objects.create(task_id=task_id)
|
||||
async_task(cdist_configure_wireguard_server,config, server).id
|
||||
|
||||
|
||||
@shared_task
|
||||
def cdist_configure_wireguard_server(config, server):
|
||||
"""
|
||||
Create config and configure server.
|
||||
|
|
|
@ -37,7 +37,7 @@ class VPNTests(TestCase):
|
|||
|
||||
self.vpn_wireguard_public_key = 'B2b78eWBIXPMM1x4DDjkCDZepS0qDgcLN3T3PjcgXkY='
|
||||
|
||||
self.vpnpool = VPNPool.objects.get_or_create(network=self.pool_network,
|
||||
self.vpnpool = WireGuardVPNPool.objects.get_or_create(network=self.pool_network,
|
||||
network_size=self.pool_network_size,
|
||||
subnetwork_size=self.pool_subnetwork_size,
|
||||
vpn_hostname=self.pool_vpn_hostname,
|
||||
|
@ -47,55 +47,6 @@ class VPNTests(TestCase):
|
|||
self.factory = APIRequestFactory()
|
||||
|
||||
|
||||
def test_create_vpnpool(self):
|
||||
url = reverse("vpnpool-list")
|
||||
view = VPNPoolViewSet.as_view({'post': 'create'})
|
||||
request = self.factory.post(url, { 'network': self.pool_network2,
|
||||
'network_size': self.pool_network_size,
|
||||
'subnetwork_size': self.pool_subnetwork_size,
|
||||
'vpn_hostname': self.pool_vpn_hostname,
|
||||
'wireguard_private_key': self.pool_wireguard_private_key
|
||||
|
||||
})
|
||||
force_authenticate(request, user=self.admin_user)
|
||||
response = view(request)
|
||||
|
||||
# This raises an exception if the request was not successful
|
||||
# No assert needed
|
||||
pool = VPNPool.objects.get(network=self.pool_network2)
|
||||
|
||||
# def test_create_vpn(self):
|
||||
# url = reverse("vpnnetwork-list")
|
||||
# view = VPNNetworkViewSet.as_view({'post': 'create'})
|
||||
# request = self.factory.post(url, { 'network_size': self.pool_subnetwork_size,
|
||||
# 'wireguard_public_key': self.vpn_wireguard_public_key
|
||||
|
||||
# })
|
||||
# force_authenticate(request, user=self.user)
|
||||
|
||||
|
||||
# # we don't have a billing address -> should raise an error
|
||||
# # with self.assertRaises(ValidationError):
|
||||
# # response = view(request)
|
||||
|
||||
# addr = BillingAddress.objects.get_or_create(
|
||||
# owner=self.user,
|
||||
# active=True,
|
||||
# defaults={'organization': 'ungleich',
|
||||
# 'name': 'Nico Schottelius',
|
||||
# 'street': 'Hauptstrasse 14',
|
||||
# 'city': 'Luchsingen',
|
||||
# 'postal_code': '8775',
|
||||
# 'country': 'CH' }
|
||||
# )
|
||||
|
||||
# # This should work now
|
||||
# response = view(request)
|
||||
|
||||
# # Verify that an order was created successfully - there should only be one order at
|
||||
# # this point in time
|
||||
# order = Order.objects.get(owner=self.user)
|
||||
|
||||
|
||||
def tearDown(self):
|
||||
self.user.delete()
|
||||
|
|
|
@ -61,10 +61,3 @@ class WireGuardVPNSizes(viewsets.ViewSet):
|
|||
print(sizes)
|
||||
|
||||
return Response(WireGuardVPNSizesSerializer(sizes, many=True).data)
|
||||
|
||||
|
||||
|
||||
# class VPNPoolViewSet(viewsets.ModelViewSet):
|
||||
# serializer_class = VPNPoolSerializer
|
||||
# permission_classes = [permissions.IsAdminUser]
|
||||
# queryset = VPNPool.objects.all()
|
||||
|
|
|
@ -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…
Reference in a new issue