diff --git a/.gitignore b/.gitignore
index ab6a151..426f11b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -22,6 +22,6 @@ uncloud/version.py
build/
venv/
dist/
-
+.history/
*.iso
*.sqlite3
diff --git a/README.md b/README.md
index 8c53654..07f5c91 100644
--- a/README.md
+++ b/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
diff --git a/matrixhosting/__init__.py b/matrixhosting/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/matrixhosting/admin.py b/matrixhosting/admin.py
new file mode 100644
index 0000000..c33589b
--- /dev/null
+++ b/matrixhosting/admin.py
@@ -0,0 +1,4 @@
+from django.contrib import admin
+from .models import VMInstance
+
+admin.site.register(VMInstance)
diff --git a/matrixhosting/apps.py b/matrixhosting/apps.py
new file mode 100644
index 0000000..ad02796
--- /dev/null
+++ b/matrixhosting/apps.py
@@ -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
diff --git a/matrixhosting/forms.py b/matrixhosting/forms.py
new file mode 100644
index 0000000..793aeeb
--- /dev/null
+++ b/matrixhosting/forms.py
@@ -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']
+
+
diff --git a/matrixhosting/migrations/0001_initial.py b/matrixhosting/migrations/0001_initial.py
new file mode 100644
index 0000000..dce49c3
--- /dev/null
+++ b/matrixhosting/migrations/0001_initial.py
@@ -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)),
+ ],
+ ),
+ ]
diff --git a/matrixhosting/migrations/0002_rename_vmpricing_matrixvmpricing.py b/matrixhosting/migrations/0002_rename_vmpricing_matrixvmpricing.py
new file mode 100644
index 0000000..f21241d
--- /dev/null
+++ b/matrixhosting/migrations/0002_rename_vmpricing_matrixvmpricing.py
@@ -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',
+ ),
+ ]
diff --git a/matrixhosting/migrations/0003_auto_20210703_1523.py b/matrixhosting/migrations/0003_auto_20210703_1523.py
new file mode 100644
index 0000000..fe45ab0
--- /dev/null
+++ b/matrixhosting/migrations/0003_auto_20210703_1523.py
@@ -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),
+ ),
+ ]
diff --git a/matrixhosting/migrations/0004_matrixhostingorder_vmspecs.py b/matrixhosting/migrations/0004_matrixhostingorder_vmspecs.py
new file mode 100644
index 0000000..0259c51
--- /dev/null
+++ b/matrixhosting/migrations/0004_matrixhostingorder_vmspecs.py
@@ -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')),
+ ],
+ ),
+ ]
diff --git a/matrixhosting/migrations/0005_auto_20210705_0849.py b/matrixhosting/migrations/0005_auto_20210705_0849.py
new file mode 100644
index 0000000..742a63f
--- /dev/null
+++ b/matrixhosting/migrations/0005_auto_20210705_0849.py
@@ -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',
+ ),
+ ]
diff --git a/matrixhosting/migrations/0006_delete_matrixvmpricing.py b/matrixhosting/migrations/0006_delete_matrixvmpricing.py
new file mode 100644
index 0000000..f6b0f01
--- /dev/null
+++ b/matrixhosting/migrations/0006_delete_matrixvmpricing.py
@@ -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',
+ ),
+ ]
diff --git a/matrixhosting/migrations/0007_vminstance.py b/matrixhosting/migrations/0007_vminstance.py
new file mode 100644
index 0000000..2990e10
--- /dev/null
+++ b/matrixhosting/migrations/0007_vminstance.py
@@ -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)),
+ ],
+ ),
+ ]
diff --git a/matrixhosting/migrations/0008_remove_vminstance_ip.py b/matrixhosting/migrations/0008_remove_vminstance_ip.py
new file mode 100644
index 0000000..054359b
--- /dev/null
+++ b/matrixhosting/migrations/0008_remove_vminstance_ip.py
@@ -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',
+ ),
+ ]
diff --git a/matrixhosting/migrations/0009_vminstance_vm_id.py b/matrixhosting/migrations/0009_vminstance_vm_id.py
new file mode 100644
index 0000000..2771f58
--- /dev/null
+++ b/matrixhosting/migrations/0009_vminstance_vm_id.py
@@ -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),
+ ),
+ ]
diff --git a/matrixhosting/migrations/__init__.py b/matrixhosting/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/matrixhosting/models.py b/matrixhosting/models.py
new file mode 100644
index 0000000..8945f69
--- /dev/null
+++ b/matrixhosting/models.py
@@ -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
\ No newline at end of file
diff --git a/matrixhosting/serializers.py b/matrixhosting/serializers.py
new file mode 100644
index 0000000..7711612
--- /dev/null
+++ b/matrixhosting/serializers.py
@@ -0,0 +1,8 @@
+from rest_framework import serializers
+
+from .models import *
+
+class VMInstanceSerializer(serializers.ModelSerializer):
+ class Meta:
+ model = VMInstance
+ fields = '__all__'
\ No newline at end of file
diff --git a/matrixhosting/signals.py b/matrixhosting/signals.py
new file mode 100644
index 0000000..494a1fc
--- /dev/null
+++ b/matrixhosting/signals.py
@@ -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)
\ No newline at end of file
diff --git a/matrixhosting/static/matrixhosting/css/common.css b/matrixhosting/static/matrixhosting/css/common.css
new file mode 100644
index 0000000..6ef2b64
--- /dev/null
+++ b/matrixhosting/static/matrixhosting/css/common.css
@@ -0,0 +1,1346 @@
+body,
+html {
+ width: 100%;
+ height: 100%;
+}
+
+body,
+h1,
+h2,
+h3,
+h4,
+h5,
+h6 {
+ font-family: 'Lato', sans-serif;
+}
+
+
+/* bootstrap danger color override from #a94442 */
+
+.text-danger,
+.has-error .help-block,
+.has-error .control-label,
+.has-error .radio,
+.has-error .checkbox,
+.has-error .radio-inline,
+.has-error .checkbox-inline,
+.has-error.radio label,
+.has-error.checkbox label,
+.has-error.radio-inline label,
+.has-error.checkbox-inline label,
+.has-error .form-control-feedback,
+.alert-danger,
+.list-group-item-danger,
+a.list-group-item-danger,
+a.list-group-item-danger:hover,
+a.list-group-item-danger:focus,
+.panel-danger>.panel-heading {
+ color: #eb4d5c;
+}
+
+.alert-danger {
+ background: rgba(235, 204, 209, 0.2);
+}
+
+.has-error .form-control,
+.has-error .form-control:focus,
+.has-error .form-control:active,
+.has-error .input-group-addon {
+ color: #eb4d5c;
+ border-color: #eb4d5c;
+}
+
+a.list-group-item-danger.active,
+a.list-group-item-danger.active:hover,
+a.list-group-item-danger.active:focus {
+ background-color: #eb4d5c;
+ border-color: #eb4d5c;
+}
+
+.panel-danger>.panel-heading .badge {
+ background-color: #eb4d5c;
+}
+
+.topnav {
+ font-size: 14px;
+}
+
+.navbar-default {
+ background: #fff;
+ padding: 5px;
+}
+
+.navbar-brand {
+ padding: 10px;
+}
+
+.navbar-brand > img {
+ height: 100%;
+}
+
+#logoWhite,
+.navbar-transparent #logoBlack {
+ display: none;
+}
+
+#logoBlack,
+.navbar-transparent #logoWhite {
+ display: block;
+}
+
+@media (min-width: 768px) {
+ .navbar-right {
+ margin-right: 10px;
+ }
+ .navbar-brand {
+ padding-right: 15px;
+ padding-left: 15px;
+ }
+}
+
+.navbar .dcl-link {
+ display: block;
+ padding: 15px;
+ color: #777;
+}
+
+.navbar .dcl-link:focus,
+.navbar .dcl-link:active,
+.navbar .dcl-link:hover {
+ text-decoration: none;
+}
+
+.navbar .dropdown-menu .dcl-link {
+ padding: 1px 10px;
+}
+
+p.copyright {
+ margin: 0;
+}
+
+footer {
+ font-weight: 300;
+ padding: 25px 0;
+ background-color: #f8f8f8;
+}
+
+footer .list-inline {
+ margin-bottom: 15px;
+}
+
+footer a {
+ color: #777;
+}
+
+footer .dcl-link-separator {
+ position: relative;
+ padding-left: 10px;
+}
+
+footer .dcl-link-separator::before {
+ content: "";
+ position: absolute;
+ display: inline-block;
+ top: 9px;
+ bottom: 0;
+ left: -2px;
+ right: 0;
+ width: 2px;
+ height: 2px;
+ border-radius: 100%;
+ background: #777;
+}
+
+.mb-0 {
+ margin-bottom: 0;
+}
+
+.thin-hr {
+ margin-top: 10px;
+ margin-bottom: 10px;
+}
+
+.payment-container .credit-card-info {
+ padding-bottom: 15px;
+ border-bottom: 1px solid #eee;
+}
+.credit-card-info {
+ display: flex;
+}
+
+.credit-card-info .align-bottom {
+ align-self: flex-end;
+ padding-right: 0 !important;
+}
+
+.new-card-head {
+ margin-top: 10px;
+}
+.new-card-button-margin button{
+ margin-top: 5px;
+ margin-bottom: 5px;
+}
+
+.input-no-border {
+ border: none !important;
+ background: transparent !important;
+ resize: none;
+}
+
+.existing-keys-title {
+ font-weight: bold;
+ font-size: 14px;
+}
+
+@media(max-width:767px) {
+ .vspace-top {
+ margin-top: 35px;
+ }
+}
+
+/* index */
+.btn {
+ box-shadow: 0 1px 4px rgba(0, 0, 0, .6);
+}
+
+.fa-li.fa-lg {
+ color: #29427A;
+ margin-top: 6px;
+}
+
+.btn-transparent {
+ background: transparent;
+ border: 2px solid #fff;
+ color: #fff;
+ transition: all .2s ease-in;
+}
+
+.btn-primary {
+ background: #29427A;
+ border-color: #29427A;
+ color: #fff;
+ width: auto;
+}
+
+.btn-primary:hover {
+ background: rgba(41, 66, 122, 0.8);
+ border-color: #29427A;
+}
+
+.btn-transparent:hover {
+ background: #fff;
+ border: 2px solid #fff;
+ color: #000;
+ transition: all .2s ease-in;
+}
+
+.btn-lg {
+ min-width: 180px;
+}
+
+.lead {
+ font-size: 18px;
+}
+
+@media (min-width: 768px) {
+ .lead {
+ font-size: 21px;
+ }
+}
+
+
+/* Top navbar */
+
+.navbar {
+ transition: all .3s ease-in;
+ font-weight: 400;
+}
+
+.navbar-default .navbar-nav>.open>a,
+.navbar-default .navbar-nav>.open>a:focus,
+.navbar-default .navbar-nav>.open>a:hover {
+ background: transparent;
+}
+
+.navbar-default .navbar-nav>.active>a,
+.navbar-default .navbar-nav>.active>a:focus,
+.navbar-default .navbar-nav>.active>a:hover {
+ background: #2D457A;
+ color: #fff;
+ border-radius: 6px;
+}
+
+@media (max-width: 767px) {
+ .navbar-default .navbar-nav>li>a{
+ font-weight: 400;
+ }
+}
+
+.navbar-transparent .navbar-nav>li a,
+.navbar-transparent .navbar-nav>.open>a,
+.navbar-transparent .navbar-nav>.open>a:focus,
+.navbar-transparent .navbar-nav>.open>a:hover {
+ color: #fff;
+}
+
+
+.navbar-transparent .navbar-nav>li a:focus,
+.navbar-transparent .navbar-nav>li a:active,
+.navbar-transparent .navbar-nav>li a:hover {
+ color: #fff;
+ background-color: transparent;
+ text-decoration: none;
+}
+
+.topnav .nav .open>a,
+.topnav .nav .open>a:focus,
+.topnav .nav .open>a:hover {
+ background: transparent;
+}
+
+.navbar-transparent .navbar-nav>li>.on-hover-border {
+ transition: all 0.3s linear;
+ box-shadow: none;
+}
+
+.navbar-transparent .navbar-nav>li>.on-hover-border:hover {
+ box-shadow: 0 0 0 1px #eee;
+ border-radius: 5px;
+}
+
+.navbar-transparent {
+ background: transparent;
+ border: none;
+ padding: 20px;
+}
+
+.navbar-transparent .nav-language .select-language {
+ color: #fff;
+}
+
+.nav-language {
+ position: relative;
+}
+
+.nav-language .select-language {
+ padding: 15px 10px;
+ color: #777;
+}
+
+.nav-language .select-language span {
+ margin-left: 5px;
+ margin-right: 5px;
+ font-weight: normal;
+}
+
+.nav-language .drop-language {
+ top: 45px;
+ left: auto !important;
+ width: 100px;
+ min-width: 100px;
+ height: 40px;
+ padding: 9px 10px;
+ -webkit-box-shadow: -8px 13px 31px -8px rgba(77, 77, 77, 1);
+ -moz-box-shadow: -8px 13px 31px -8px rgba(77, 77, 77, 1);
+ box-shadow: -8px 13px 31px -8px rgba(77, 77, 77, 1);
+ z-index: 100;
+ text-align: center;
+ border-radius: 4px;
+}
+
+.nav-language .drop-language a {
+ cursor: pointer;
+ padding: 5px 10px !important;
+}
+
+.nav-language .open .drop-language {
+ width: 100px;
+ min-width: 100px;
+}
+
+.dropdown-menu {
+ border: 1px solid #fff;
+ -webkit-box-shadow: -8px 14px 20px -5px rgba(77, 77, 77, 0.5);
+ -moz-box-shadow: -8px 14px 20px -5px rgba(77, 77, 77, 0.5);
+ box-shadow: -8px 14px 20px -5px rgba(77, 77, 77, 0.5);
+ border-radius: 4px !important;
+ left: 0 !important;
+ min-width: 155px;
+ padding: 5px;
+ margin-left: 15px;
+}
+
+.dropdown-menu>li a:focus,
+.dropdown-menu>li a:hover {
+ background: transparent;
+ text-decoration: underline !important;
+}
+
+@media (min-width: 768px) {
+ .dropdown-menu>li>a {
+ font-weight: 300;
+ }
+}
+
+.highlights-dropdown .dropdown-menu>li>a {
+ font-size: 13px;
+ padding: 1px 10px;
+}
+
+
+/* Show the dropdown menu on hover */
+
+@media (min-width: 768px) {
+ .nav-language .dropdown:hover .dropdown-menu {
+ display: block;
+ }
+}
+
+@media (max-width: 767px) {
+ .nav-language .open .dropdown-menu>li>a {
+ line-height: 1.42857143;
+ }
+}
+
+.navbar-transparent .nav-language .drop-language {
+ background: transparent;
+ border: 1px solid #fff;
+}
+
+.navbar-transparent .nav-language .drop-language a {
+ color: #fff;
+ padding: 5px 10px !important;
+}
+
+
+/* dcl header */
+.dcl-header {
+ padding: 150px 0 150px 0;
+ text-align: center;
+ color: #f8f8f8;
+ background: url(../img/pattern.jpg) no-repeat center center;
+ background-size: cover;
+ position: relative;
+ background-attachment: fixed;
+}
+
+.dcl-header::before {
+ content: "";
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ background: rgba(90, 116, 175, 0.85);
+}
+
+.dcl-header .container {
+ position: relative;
+}
+
+.dcl-header h1 {
+ font-size: 65px;
+ margin: 0;
+ padding: 0;
+}
+
+@media(max-width:767px) {
+ .dcl-header h1 {
+ font-size: 50px;
+ }
+}
+
+.intro-header {
+ min-height: 100vh;
+ text-align: center;
+ color: #fff;
+ background: url(../img/configure.jpg) no-repeat center center;
+ background-size: cover;
+ position: relative;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+}
+
+.intro-header::before {
+ content: "";
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ background: rgba(38, 59, 107, 0.7);
+}
+
+.intro-header-2 {
+ padding-top: 50px;
+ /* If you're making other pages, make sure there is 50px of padding to make sure the navbar doesn't overlap content! */
+ padding-bottom: 50px;
+ color: #f8f8f8;
+ background: url(../img/pattern.jpg) no-repeat center center;
+ background-size: cover;
+ position: relative;
+}
+
+.intro-header-2::before {
+ content: "";
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ background: rgba(41, 66, 122, 0.59);
+}
+
+.intro-message {
+ position: relative;
+ width: 80%;
+ margin: 0 auto;
+}
+
+.intro-message>h1 {
+ margin: 0;
+ font-size: 6em;
+}
+
+.intro-divider {
+ width: 400px;
+ border-top: 1px solid #f8f8f8;
+ border-bottom: 1px solid rgba(0, 0, 0, 0.2);
+}
+
+.intro-pricing {
+ text-align: center;
+ color: #fff;
+ background: url(../img/pattern.jpg) no-repeat center center;
+ background-size: cover;
+ height: 70vh;
+ max-height: 400px;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ position: relative;
+}
+
+.intro-pricing.success-pricing {
+ height: 100vh;
+ max-height: 100vh;
+}
+
+.intro-pricing::before {
+ content: "";
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ background: rgba(90, 116, 175, 0.7);
+}
+
+.intro-pricing .intro-message .section-heading {
+ font-size: 45px;
+ width: 80%;
+ margin: 0 auto;
+}
+
+.split-section {
+ padding: 70px 0;
+ border-top: 1px solid #f6f7f8;
+}
+
+.split-section .icon-section {
+ position: relative;
+ min-height: 330px;
+}
+
+.split-section .icon-section i {
+ position: absolute;
+ left: 50%;
+ top: 50%;
+ transform: translate(-50%, -50%);
+ font-size: 216px;
+ color: #5A74AF;
+}
+
+.split-section h2 {
+ font-size: 36px;
+ font-weight: 400;
+}
+
+.split-section .split-title-plain h2 {
+ font-size: 40px;
+ font-weight: 300;
+ line-height: 50px;
+ color: #3a3a3a;
+}
+
+.split-section .split-title {
+ position: relative;
+ margin-bottom: 25px;
+}
+
+.split-section .split-title h2 {
+ font-size: 50px;
+ font-weight: 300;
+ padding-bottom: 25px;
+ letter-spacing: 2px;
+}
+
+.section-gradient {
+ background: -webkit-linear-gradient(#f0f4f7, #fff) no-repeat;
+ background: -o-linear-gradient(#f0f4f7, #fff) no-repeat;
+ background: linear-gradient(#f0f4f7, #fff) no-repeat;
+}
+
+.split-section.left .split-description {
+ margin-right: auto;
+}
+
+.split-section .split-description .lead {
+ color: #3a3a3a;
+}
+
+@media (min-width: 768px) {
+ .split-section .split-description .lead {
+ font-size: 21px;
+ }
+ .split-section .space .split-description .lead {
+ font-size: 20px;
+ }
+}
+
+.split-section.right .split-description {
+ width: 90%;
+ margin-left: auto;
+}
+
+.split-section.right .split-description.title p {
+ font-size: 27px;
+ margin-bottom: 10px;
+ text-align: left;
+}
+
+.split-section.right .split-text ul,
+.split-section.left .split-text,
+.split-section.left .space {
+ text-align: left;
+}
+
+.split-section.right .split-text,
+.split-section.right .space {
+ text-align: right;
+}
+
+.split-section .split-title::before {
+ content: "";
+ position: absolute;
+ bottom: 0;
+ background: #29427A;
+ height: 7px;
+ width: 70px;
+ left: auto;
+}
+
+.split-section.right .split-title::before {
+ right: 0;
+}
+
+.split-section.left .split-title::before {
+ left: 0;
+}
+
+.section-figure {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: center;
+ text-align: center;
+}
+
+.section-figure .section-image {
+ padding: 20px 40px 30px;
+ flex-basis: 50%;
+ flex-grow: 1;
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+}
+
+@media (max-width: 767px) {
+ .section-figure .section-image {
+ flex-basis: 100%;
+ }
+}
+
+.split-section-plain .section-figure .section-image {
+ flex-grow: 0;
+ padding: 50px 15px 0;
+}
+
+.split-section-plain .section-figure {
+ justify-content: flex-start;
+}
+
+@media (min-width: 768px) {
+ .split-section-plain .split-figure {
+ width: 41.66666667%;
+ }
+ .split-section-plain .split-figure.col-sm-pull-6 {
+ right: 58.33333333%;
+ }
+ .split-section-plain .split-text {
+ width: 58.33333333%;
+ }
+ .split-section-plain .split-text.col-sm-push-6 {
+ left: 41.66666667%;
+ }
+}
+
+.section-image img {
+ margin: auto;
+}
+
+.section-image-caption {
+ padding-top: 20px;
+ display: inline-block;
+ color: #999 !important;
+ word-break: break-all;
+}
+
+.price-calc-section .card {
+ width: 350px;
+ margin: 0 auto;
+ background: #fff;
+ box-shadow: 0 10px 20px rgba(0, 0, 0, 0.19), 0 6px 6px rgba(0, 0, 0, 0.23);
+ padding-bottom: 40px;
+ border-radius: 7px;
+ position: relative;
+}
+
+.price-calc-section .card .title {
+ padding: 15px 40px;
+}
+
+.price-calc-section .card .price {
+ background: #5A74AF;
+ padding: 22px;
+ color: #fff;
+ font-size: 32px;
+}
+
+.price-calc-section .card .description {
+ padding: 12px;
+}
+
+.price-calc-section .card .descriptions {
+ padding: 10px 30px;
+}
+
+.price-calc-section .card .description p {
+ margin: 0;
+}
+
+.price-calc-section .card .btn {
+ margin-top: 20px;
+}
+
+@keyframes sending {
+ 0% {
+ content: '.';
+ }
+ 50% {
+ content: '..';
+ }
+ 100% {
+ content: '...';
+ }
+}
+/*Why DCL*/
+
+#tech_stack {
+ background: #fff;
+}
+
+#tech_stack h3 {
+ font-size: 42px;
+ width: 70%;
+}
+
+.space {
+ max-width: 660px;
+ margin: auto;
+}
+
+.percent-text {
+ font-size: 50px;
+ color: #999;
+}
+
+.space-middle {
+ /* padding: 45px 0; */
+ display: inline-block;
+}
+
+.ssdimg {
+ margin: 0 15px;
+}
+
+@media (max-width: 767px) {
+ .ssdimg img {
+ max-height: 120px;
+ }
+}
+
+.padding-vertical {
+ padding: 30px 2px 20px;
+}
+
+
+/*Pricing page*/
+
+.price-calc-section {
+ display: flex;
+ margin-top: 25px;
+ margin-bottom: 25px;
+}
+
+.price-calc-section .card {
+ width: 100%;
+ margin: 0 auto;
+ background: #fff;
+ box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1), 0 0 6px rgba(0, 0, 0, 0.15);
+ padding-bottom: 40px;
+ border-radius: 7px;
+ text-align: center;
+ max-width: 400px;
+ position: relative;
+}
+
+.price-calc-section .card .title {
+ padding: 15px 40px;
+}
+
+.price-calc-section .card .title h3 {
+ font-weight: normal;
+}
+
+.price-calc-section .card .price {
+ background: #5A74AF;
+ padding: 22px;
+ color: #fff;
+ font-size: 32px;
+}
+
+.price-calc-section .card .price .price-text {
+ font-size: 14px;
+}
+
+.price-calc-section .card .description {
+ padding: 12px;
+ position: relative;
+ display: flex;
+ justify-content: space-around !important;
+ align-items: center !important;
+}
+
+.price-calc-section .card .description span {
+ font-size: 16px;
+ margin-left: 0px;
+ width: 30%;
+ text-align: left;
+}
+
+.price-calc-section .card .description .select-number {
+ font-size: 20px;
+ text-align: center;
+ width: 85px;
+}
+
+.price-calc-section .card .description i {
+ color: #29427A;
+ cursor: pointer;
+ font-size: 24px;
+}
+
+.price-calc-section .card .description .left {
+ margin-right: 7px;
+}
+
+.price-calc-section .card .description .right {
+ margin-left: 7px;
+}
+
+.price-calc-section .card .descriptions {
+ padding: 10px 30px;
+}
+
+.price-calc-section .card .description p {
+ margin: 0;
+}
+
+.price-calc-section .card .btn {
+ margin-top: 20px;
+ font-size: 20px;
+ width: 200px;
+ border: none;
+}
+
+.price-calc-section .card .select-configuration select {
+ outline: none;
+ background: #fff;
+ border-color: #d0d0d0;
+ height: 40px;
+ width: 200px;
+ text-align: center;
+ font-size: 16px;
+ margin-left: 10px;
+}
+
+.price-calc-section .card .check-ip {
+ font-size: 18px;
+}
+
+.price-calc-section .card .justify-center {
+ justify-content: center !important;
+}
+
+.price-calc-section .card .description.input label {
+ font-size: 15px;
+ font-weight: 700;
+ margin-bottom: 0;
+ width: 40px;
+}
+
+
+/*Changed class****.price-calc-section .card .description.input input*/
+
+.price-calc-section .card .description input {
+ width: 200px;
+ font-size: 14px;
+ text-align: left;
+ padding: 5px 10px;
+ border-radius: 4px;
+ border: 1px solid #d0d0d0;
+ background: #fff;
+ margin-left: 10px;
+}
+
+.price-calc-section .card .check-ip input[type=checkbox] {
+ font-size: 17px;
+ margin: 0 8px;
+}
+
+.help-block.with-errors {
+ text-align: center;
+ margin: 0;
+ padding: 0;
+}
+
+.form-group {
+ margin: 0;
+ border-bottom: 1px solid rgba(128, 128, 128, 0.3);
+}
+
+@media(max-width:767px) {
+ #tech_stack h3 {
+ font-size: 30px;
+ line-height: 40px;
+ width: 100%;
+ }
+ .navbar-nav .open .dropdown-menu {
+ text-align: left;
+ font-size: 12px;
+ }
+
+ .navbar-default .navbar-nav>.open>a,
+ .navbar-default .navbar-nav>.open>a:focus,
+ .navbar-default .navbar-nav>.open>a:hover {
+ background: transparent;
+ color: #777 !important;
+ }
+}
+
+
+@media(max-width:767px) {
+ .section-sm-center .split-text,
+ .section-sm-center .space {
+ text-align: center !important;
+ margin-bottom: 40px;
+ }
+ .section-sm-center .split-title::before {
+ left: 50% !important;
+ transform: translate(-50%, 0);
+ }
+ .section-sm-center .split-description {
+ width: 100% !important;
+ }
+}
+
+@media(max-width:767px) {
+ .navbar-transparent li a {
+ color: #777 !important;
+ }
+ .intro-message {
+ padding-bottom: 15%;
+ }
+ .intro-message>h1 {
+ font-size: 3em;
+ }
+ ul.intro-social-buttons>li {
+ display: block;
+ margin-bottom: 20px;
+ padding: 0;
+ }
+ .intro-pricing .intro-message .section-heading {
+ font-size: 35px;
+ width: 80%;
+ margin: 0 auto;
+ }
+ .intro-pricing .intro-message {
+ padding-bottom: 0;
+ }
+ ul.intro-social-buttons>li:last-child {
+ margin-bottom: 0;
+ }
+ .intro-divider {
+ width: 100%;
+ }
+ .navbar-transparent {
+ background: #fff;
+ border: none;
+ padding: 5px;
+ }
+ .navbar-transparent #logoBlack {
+ display: block;
+ }
+ .navbar-transparent #logoWhite {
+ display: none;
+ }
+ .navbar-transparent .nav-language .select-language {
+ color: #777;
+ }
+ .navbar-transparent .nav-language .drop-language a {
+ color: #777;
+ }
+ .navbar-transparent .nav-language .drop-language {
+ background: #fff;
+ z-index: 100000;
+ left: 9px;
+ border: 1px solid rgba(119, 119, 119, 0.4);
+ box-shadow: none;
+ }
+ .navbar-default .nav-language .drop-language {
+ background: #fff;
+ z-index: 100000;
+ left: 9px;
+ border: 1px solid rgba(119, 119, 119, 0.4);
+ box-shadow: none;
+ }
+ .navbar-default .nav-language .select-language {
+ color: #777;
+ }
+ .navbar-default .nav-language .drop-language a {
+ color: #777;
+ }
+ .navbar-transparent .navbar-nav>li>a:focus,
+ .navbar-transparent .navbar-nav>li>a:hover {
+ color: #333;
+ background-color: transparent;
+ }
+ .navbar-default .navbar-nav>li>a:focus,
+ .navbar-default .navbar-nav>li>a:hover {
+ color: #333;
+ background-color: transparent;
+ }
+ .split-section {
+ padding: 20px 0;
+ }
+ .split-section .icon-section {
+ min-height: 160px;
+ }
+ .split-section .icon-section i {
+ font-size: 120px;
+ }
+ .split-section h2 {
+ font-size: 28px;
+ }
+ .split-section .split-title-plain h2 {
+ font-size: 30px;
+ line-height: 35px;
+ }
+ .split-section .split-title h2 {
+ font-size: 32px;
+ line-height: 34px;
+ }
+ .contact-section .title {
+ margin: 0 auto;
+ }
+ .contact-section .title h2 {
+ font-size: 45px;
+ line-height: 40px;
+ margin-top: 35px;
+ }
+ .contact-section .title h2::before {
+ left: 50%;
+ transform: translate(-50%, 0);
+ }
+ .contact-section .card .social a {
+ color: #29427A;
+ font-size: 30px;
+ }
+ .intro-pricing .intro-message .section-heading {
+ font-size: 30px;
+ }
+ .price-calc-section {
+ flex-direction: column;
+ /* padding: 60px 10px !important; */
+ }
+ .price-calc-section .card {
+ width: 90%;
+ }
+ .price-calc-section .text {
+ width: 80%;
+ text-align: center;
+ margin: 0 auto;
+ margin-top: 20px;
+ }
+ .price-calc-section .text .section-heading {
+ font-size: 35px;
+ line-height: 35px;
+ padding-bottom: 15px;
+ text-align: center;
+ }
+ .price-calc-section .text .section-heading::before {
+ left: 50%;
+ transform: translate(-50%, 0);
+ }
+ .price-calc-section .text .description {
+ font-size: 18px;
+ text-align: center;
+ }
+ .price-calc-section .card .description .select-number {
+ font-size: 17px;
+ text-align: center;
+ width: 60px;
+ }
+}
+
+@media(max-width:575px) {
+ .percent-text {
+ font-weight: normal;
+ font-size: 37px;
+ }
+ .contact-section .card {
+ width: 90%;
+ }
+ .form-beta {
+ width: 90%;
+ padding: 25px 10px;
+ }
+ .intro-message>h1 {
+ font-size: 2em;
+ }
+ .price-calc-section .text .section-heading {
+ font-size: 24px;
+ line-height: 25px;
+ }
+ .price-calc-section .card .description span {
+ font-size: 15px;
+ }
+}
+
+.network-name {
+ text-transform: uppercase;
+ font-size: 14px;
+ font-weight: 300;
+ letter-spacing: 2px;
+ line-height: 24px;
+ display: block;
+}
+
+.section-heading {
+ margin-bottom: 30px;
+}
+
+footer {
+ padding: 50px 20px;
+}
+
+.topnav a:focus {
+ outline: none;
+ outline-offset: 0;
+}
+
+.topnav .btn:focus {
+ outline: none !important;
+ outline-offset: 0;
+}
+
+.flex-row-rev {
+ margin-top: 25px;
+}
+
+.flex-row .percent-text {
+ display: flex;
+ align-items: center;
+}
+
+@media (min-width: 768px) {
+ .flex-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ }
+ .flex-row .percent-text {
+ flex-shrink: 0;
+ padding: 0 15px;
+ }
+ .flex-row .desc-text {
+ text-align: right;
+ }
+ .flex-row .desc-text,
+ .flex-row .percent-text {
+ max-width: 430px;
+ }
+ .flex-row-rev .desc-text {
+ max-width: 600px;
+ text-align: left;
+ }
+ .flex-row-rev .percent-text {
+ order: 2;
+ }
+ .flex-row-rev {
+ margin-bottom: 25px;
+ }
+}
+
+.checkmark {
+ display: inline-block;
+}
+
+.checkmark:after {
+ /*Add another block-level blank space*/
+ content: '';
+ display: block;
+ /*Make it a small rectangle so the border will create an L-shape*/
+ width: 25px;
+ height: 60px;
+ /*Add a white border on the bottom and left, creating that 'L' */
+ border: solid #777;
+ border-width: 0 3px 3px 0;
+ /*Rotate the L 45 degrees to turn it into a checkmark*/
+ transform: rotate(45deg);
+}
+
+
+/* new styles for whydcl section cms plugin (to replace older style) */
+
+.banner-list {
+ border-top: 2px solid #eee;
+ padding: 50px 0;
+}
+
+.banner-list-heading h2 {
+ font-size: 42px;
+}
+
+@media (max-width: 767px) {
+ .banner-list-heading h2 {
+ font-size: 30px;
+ }
+}
+
+
+/* cms section promo */
+
+.promo-section {
+ padding: 75px 15px;
+}
+
+.promo-section.promo-with-bg {
+ color: #fff;
+ background-size: cover;
+ background-position: center;
+}
+
+.promo-section.promo-with-bg a {
+ color: #87B6EA;
+}
+
+.promo-section.promo-with-bg a:hover,
+.promo-section.promo-with-bg a:focus {
+ color: #77a6da;
+}
+
+.promo-section h3 {
+ font-weight: 700;
+ font-size: 36px;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ margin-top: 10px;
+ margin-bottom: 25px;
+}
+
+.promo-section h4 {
+ font-size: 24px;
+ margin-bottom: 20px;
+}
+
+.promo-section p {
+ font-size: 18px;
+ line-height: 1.5;
+}
+
+.promo-section.text-center p {
+ max-width: 720px;
+ margin: auto;
+}
+
+.promo-section.text-center h3,
+.promo-section.text-center h4 {
+ margin-bottom: 35px;
+}
+
+.split-text .split-subsection {
+ margin-top: 25px;
+ margin-bottom: 25px;
+}
+
+.split-text .promo-section {
+ padding: 20px 15px;
+ margin-top: 30px;
+ margin-bottom: 30px;
+}
+
+.split-text .promo-section .container {
+ width: auto;
+}
+
+.split-text .promo-section h3,
+.split-text .promo-section h4 {
+ margin-bottom: 15px;
+}
+
+@media (max-width: 767px) {
+ .split-text .split-subsection {
+ margin-left: -15px;
+ margin-right: -15px;
+ }
+ .promo-section h3 {
+ font-size: 29px;
+ }
+ .split-text .promo-section {
+ padding-left: 0;
+ padding-right: 0;
+ }
+}
+
+ul.errorlist {
+ padding-left: 0px;
+}
+ul.errorlist > li {
+ color: red;
+ list-style-type: none;
+}
+div.domain {
+ flex-direction: column;
+}
\ No newline at end of file
diff --git a/matrixhosting/static/matrixhosting/css/hosting.css b/matrixhosting/static/matrixhosting/css/hosting.css
new file mode 100644
index 0000000..b3e0bbb
--- /dev/null
+++ b/matrixhosting/static/matrixhosting/css/hosting.css
@@ -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;
+ }
+
\ No newline at end of file
diff --git a/matrixhosting/static/matrixhosting/js/main.js b/matrixhosting/static/matrixhosting/js/main.js
new file mode 100644
index 0000000..52ae598
--- /dev/null
+++ b/matrixhosting/static/matrixhosting/js/main.js
@@ -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);
diff --git a/matrixhosting/static/matrixhosting/js/order.js b/matrixhosting/static/matrixhosting/js/order.js
new file mode 100644
index 0000000..af733bf
--- /dev/null
+++ b/matrixhosting/static/matrixhosting/js/order.js
@@ -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");
+ }
+ }
+ });
+ }
+});
\ No newline at end of file
diff --git a/matrixhosting/static/matrixhosting/js/payment.js b/matrixhosting/static/matrixhosting/js/payment.js
new file mode 100644
index 0000000..d009b78
--- /dev/null
+++ b/matrixhosting/static/matrixhosting/js/payment.js
@@ -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(' ');
+ 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);
+ });
+
+});
diff --git a/matrixhosting/tasks.py b/matrixhosting/tasks.py
new file mode 100644
index 0000000..c681e8c
--- /dev/null
+++ b/matrixhosting/tasks.py
@@ -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)
\ No newline at end of file
diff --git a/matrixhosting/templates/matrixhosting/base.html b/matrixhosting/templates/matrixhosting/base.html
new file mode 100644
index 0000000..3abc7a2
--- /dev/null
+++ b/matrixhosting/templates/matrixhosting/base.html
@@ -0,0 +1,60 @@
+{% load static i18n %}
+{% get_current_language as LANGUAGE_CODE %}
+{% load bootstrap5 %}
+
+
+
+
+
+
+
+
+
+
+ Matrix Hosting - {% block title %} made in Switzerland{% endblock %}
+
+
+
+ {% bootstrap_css %}
+
+
+
+
+ {% block css_extra %}
+ {% endblock css_extra %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {% block navbar %}
+ {% include "matrixhosting/includes/_navbar.html" %}
+ {% endblock navbar %}
+
+ {% block content %}
+ {% endblock %}
+
+ {% include "matrixhosting/includes/_footer.html" %}
+
+
+
+
+
+ {% bootstrap_javascript %}
+
+
+ {% block js_extra %}
+ {% endblock js_extra %}
+
+
diff --git a/matrixhosting/templates/matrixhosting/dashboard.html b/matrixhosting/templates/matrixhosting/dashboard.html
new file mode 100644
index 0000000..d183665
--- /dev/null
+++ b/matrixhosting/templates/matrixhosting/dashboard.html
@@ -0,0 +1,127 @@
+{% extends "matrixhosting/base.html" %} {% load static i18n %}
+{% block content%}
+
+{% csrf_token %}
+
+
+
+
+
+
+
+ #
+ Description
+ Starting At
+ Config
+ Pricing Plan
+ OneTime Price
+ Recurring Price
+ Ending At
+
+
+
+
+ {% for object in object_list %}
+
+ {{ object.id }}
+ {{ object.description }}
+ {{ object.starting_date }}
+ {{ object.config }}
+ {{ object.pricing_plan}}
+ {{ object.one_time_price }}
+ {{ object.recurring_price }}
+ {{ object.ending_date }}
+ {% if object.ending_date %}
+
+ {% else %}
+
+
+ Cancel
+
+
+ {% endif %}
+
+ {% endfor %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Are you sure that you want to cancel this subscription?.
+
+ The instance will be active till the end date of the last bill and will be deleted
+ after that.
+
+
+
+
+
+
+
+
+
+{% endblock %}
+
+{% block js_extra %}
+
+{% endblock %}
diff --git a/matrixhosting/templates/matrixhosting/emails/renewal_warning.html b/matrixhosting/templates/matrixhosting/emails/renewal_warning.html
new file mode 100644
index 0000000..75a4782
--- /dev/null
+++ b/matrixhosting/templates/matrixhosting/emails/renewal_warning.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ Renewal Warning
+
+
+ hello {{name}},
+ {{message}}
+
+
\ No newline at end of file
diff --git a/matrixhosting/templates/matrixhosting/includes/_calculator_form.html b/matrixhosting/templates/matrixhosting/includes/_calculator_form.html
new file mode 100644
index 0000000..fb4ce57
--- /dev/null
+++ b/matrixhosting/templates/matrixhosting/includes/_calculator_form.html
@@ -0,0 +1,101 @@
+{% load static i18n %}
+
+
\ No newline at end of file
diff --git a/matrixhosting/templates/matrixhosting/includes/_card.html b/matrixhosting/templates/matrixhosting/includes/_card.html
new file mode 100644
index 0000000..ec30a7d
--- /dev/null
+++ b/matrixhosting/templates/matrixhosting/includes/_card.html
@@ -0,0 +1,43 @@
+{% load i18n %}
+
\ No newline at end of file
diff --git a/matrixhosting/templates/matrixhosting/includes/_footer.html b/matrixhosting/templates/matrixhosting/includes/_footer.html
new file mode 100644
index 0000000..07d9fb6
--- /dev/null
+++ b/matrixhosting/templates/matrixhosting/includes/_footer.html
@@ -0,0 +1,18 @@
+{% load i18n %}
+
diff --git a/matrixhosting/templates/matrixhosting/includes/_navbar.html b/matrixhosting/templates/matrixhosting/includes/_navbar.html
new file mode 100644
index 0000000..599c6b9
--- /dev/null
+++ b/matrixhosting/templates/matrixhosting/includes/_navbar.html
@@ -0,0 +1,33 @@
+{% load static i18n %}
+{% get_current_language as LANGUAGE_CODE %}
+
+
+
\ No newline at end of file
diff --git a/matrixhosting/templates/matrixhosting/index.html b/matrixhosting/templates/matrixhosting/index.html
new file mode 100644
index 0000000..ab9085c
--- /dev/null
+++ b/matrixhosting/templates/matrixhosting/index.html
@@ -0,0 +1,21 @@
+{% extends "matrixhosting/base.html" %}
+{% load static i18n %}
+
+{% block content %}
+
+
+
+
+
+
+
+
+ {% include "matrixhosting/includes/_calculator_form.html" %}
+
+
+
+
+
+
+
+{% endblock %}
diff --git a/matrixhosting/templates/matrixhosting/order_detail.html b/matrixhosting/templates/matrixhosting/order_detail.html
new file mode 100644
index 0000000..b6942e3
--- /dev/null
+++ b/matrixhosting/templates/matrixhosting/order_detail.html
@@ -0,0 +1,268 @@
+{% load static i18n %}
+{% load bootstrap5 %}
+
+
+
+
+
+
+
+
+
+ Matrix Hosting - {% block title %} made in Switzerland{% endblock %}
+
+
+
+ {% bootstrap_css %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {% if messages %}
+
+ {% for message in messages %}
+ {{ message }}
+ {% endfor %}
+
+ {% endif %}
+ {% if not error %}
+
+
+ {% blocktrans with page_header_text=page_header_text|default:"Order" %}{{page_header_text}}{% endblocktrans %}
+
+
+
+
+
+
+ {% trans "Billed to" %}:
+
+ {% with request.session.billing_address_data as billing_address %}
+ {{billing_address.full_name}}
+ {{billing_address.street}}, {{billing_address.postal_code}}
+ {{billing_address.city}}, {{billing_address.country}}
+ {% if billing_address.vat_number %}
+ {% 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" %}
+
+ {% else %}
+
+ {% endif %}
+ {% endif %}
+ {% endif %}
+ {% endwith %}
+
+
+
+
+
+
{% trans "Payment method" %}:
+
+ {{card.brand|default:_('Credit Card')}} {% trans "ending in" %} ****{{card.last4}}
+ {% trans "Expiry" %} {{card.exp_year}}/{{card.exp_month}}
+ {{request.user.email}}
+
+
+
+
+
{% trans "Order summary" %}
+
+
+ {% trans "Product" %}:
+ Matrix Chat Hosting
+
+
+
+
+ {% trans "Cores" %}:
+ {{order.cores}}
+
+
+ {% trans "Memory" %}:
+ {{order.memory}} GB
+
+
+ {% trans "Disk space" %}:
+ {{order.storage}} GB
+
+
+
+
+
+
+
+ {% trans "Price Before VAT" %}
+ {{pricing.subtotal|floatformat:2}} CHF
+
+
+
+
+
+
+
+
+
+
{% trans "Pre VAT" %}
+
+
+
+
+
+
+
{{pricing.subtotal|floatformat:2}} CHF
+
+
+
{{pricing.price_with_vat|floatformat:2}} CHF
+
+
+ {% if pricing.discount.amount > 0 %}
+
+
+
{{pricing.discount.name}}
+
+
+
-{{pricing.discount.amount|floatformat:2}} CHF
+
+
+
-{{pricing.discount.amount_with_vat|floatformat:2}} CHF
+
+
+ {% endif %}
+
+
+
+
+
+
+
+
+
{{pricing.subtotal_after_discount|floatformat:2}} CHF
+
+
+
{{pricing.price_after_discount_with_vat|floatformat:2}} CHF
+
+
+
+
+
+
+
+ {% trans "Your Price in Total" %}
+ {{pricing.total_price|floatformat:2}} CHF
+
+
+
+
+
+
+ {% endif %}
+
+
+
+
+
+
+
+
+
+
+
+ {% trans "Processing..." %}
+
+
+
+ {% trans "Hold tight, we are processing your request" %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {% bootstrap_javascript %}
+
+
+
+
diff --git a/matrixhosting/templates/matrixhosting/payment.html b/matrixhosting/templates/matrixhosting/payment.html
new file mode 100644
index 0000000..e296d0c
--- /dev/null
+++ b/matrixhosting/templates/matrixhosting/payment.html
@@ -0,0 +1,169 @@
+{% load static i18n %}
+{% load bootstrap5 %}
+
+
+
+
+
+
+
+
+
+
+ Matrix Hosting - {% block title %} made in Switzerland{% endblock %}
+
+
+
+ {% bootstrap_css %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{%trans "Your Order" %}
+
+
+
{% trans "Cores"%} {{request.session.order.cores|floatformat}}
+
+
{% trans "Memory"%} {{request.session.order.memory|floatformat}} GB
+
+
{% trans "Disk space"%} {{request.session.order.storage|floatformat}} GB
+
+
+ {%trans "Total" %}
+
+ ({% if matrix_vm_pricing.vat_inclusive %}{%trans "including VAT" %}{% else %}{%trans "excluding VAT" %}{% endif %})
+
+ {{request.session.order.subtotal|floatformat}} CHF / {% trans "Month" %}
+
+
+ {% if matrix_vm_pricing.discount_amount %}
+
+ {{ request.session.order.discount.name }}
+ - {{ request.session.order.discount.amount }} CHF / {% trans "Month" %}
+
+ {% endif %}
+
+
+
+
+
{%trans "Billing Address"%}
+
+ {% for message in messages %}
+ {% if 'vat_error' in message.tags %}
+
+ {% endif %}
+ {% endfor %}
+
+
+
+
+
+
+
+ {% with cards_len=cards|length %}
+
{%trans "Credit Card"%}
+
+
+ {% 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 Stripe 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 Stripe for payment and do not store your information in our database.{% endblocktrans %}
+ {% endif %}
+
+
+ {% for card in cards %}
+
+
+
{% trans "Credit Card" %}
+ {% trans "Last" %} 4: ***** {{card.last4}}
+ {% trans "Type" %}: {{card.brand}}
+ {% trans "Expiry" %}: {{card.month}}/{{card.year}}
+
+
+
+ {% endfor %}
+ {% if cards_len > 0 %}
+
+
+
+
{% trans "Add a new credit card" %}
+
+
+
+ {% trans "NEW CARD" %}
+
+
+
+
+
+
+
+
{%trans "New Credit Card" %}
+
+ {% include "matrixhosting/includes/_card.html" %}
+
+
+ {% else%}
+ {% include "matrixhosting/includes/_card.html" %}
+ {% endif %}
+
+ {% endwith %}
+
+
+
+
+ {% if stripe_key %}
+ {% get_current_language as LANGUAGE_CODE %}
+
+ {%endif%}
+
+
+
+
+
+
+
+
+ {% bootstrap_javascript %}
+
+
+
+
diff --git a/matrixhosting/tests.py b/matrixhosting/tests.py
new file mode 100644
index 0000000..27468b3
--- /dev/null
+++ b/matrixhosting/tests.py
@@ -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)
+
+
diff --git a/matrixhosting/urls.py b/matrixhosting/urls.py
new file mode 100644
index 0000000..318ef8b
--- /dev/null
+++ b/matrixhosting/urls.py
@@ -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//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'),
+]
diff --git a/matrixhosting/validators.py b/matrixhosting/validators.py
new file mode 100644
index 0000000..08cc818
--- /dev/null
+++ b/matrixhosting/validators.py
@@ -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}(?
uncloud
+
Matrix Hosting
@@ -14,11 +15,11 @@
Logged in as {{ user }}. Your balance:
{{ balance }} CHF.
- Logout
+ Logout
{% else %}
- Login
+ Login
{% endif %}
diff --git a/uncloud/templates/uncloud/index.html b/uncloud/templates/uncloud/index.html
index 19fc436..b8b5828 100644
--- a/uncloud/templates/uncloud/index.html
+++ b/uncloud/templates/uncloud/index.html
@@ -38,7 +38,7 @@
First you need
to register an
account . If you already have one, you can
- login .
+ login .
If you have forgotten your password or other issues with
logging in, you can contact the ungleich support
via support at ungleich.ch .
@@ -107,8 +107,11 @@
@@ -138,11 +141,30 @@
Payments are only possible in CHF.
- Bills are not yet visible (payments are, though)
-
+ {% if user.is_authenticated %}
+
+ {% endif %}
{% endblock %}
diff --git a/uncloud/urls.py b/uncloud/urls.py
index f72a286..14e45fd 100644
--- a/uncloud/urls.py
+++ b/uncloud/urls.py
@@ -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"),
]
diff --git a/uncloud/views.py b/uncloud/views.py
index 37542bb..a4bf683 100644
--- a/uncloud/views.py
+++ b/uncloud/views.py
@@ -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')
\ No newline at end of file
diff --git a/uncloud_net/services.py b/uncloud_net/services.py
index 9149f01..8449394 100644
--- a/uncloud_net/services.py
+++ b/uncloud_net/services.py
@@ -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
diff --git a/uncloud_net/tasks.py b/uncloud_net/tasks.py
index 7d94f3b..5684871 100644
--- a/uncloud_net/tasks.py
+++ b/uncloud_net/tasks.py
@@ -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.
diff --git a/uncloud_net/tests.py b/uncloud_net/tests.py
index 4491551..75bdafa 100644
--- a/uncloud_net/tests.py
+++ b/uncloud_net/tests.py
@@ -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()
diff --git a/uncloud_net/views.py b/uncloud_net/views.py
index 7dadbf4..8e7e81b 100644
--- a/uncloud_net/views.py
+++ b/uncloud_net/views.py
@@ -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()
diff --git a/uncloud_pay/admin.py b/uncloud_pay/admin.py
index f604283..cb7b650 100644
--- a/uncloud_pay/admin.py
+++ b/uncloud_pay/admin.py
@@ -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)
diff --git a/uncloud_pay/management/commands/charge-negative-balance.py b/uncloud_pay/management/commands/charge-negative-balance.py
index 8ee8736..8405bd3 100644
--- a/uncloud_pay/management/commands/charge-negative-balance.py
+++ b/uncloud_pay/management/commands/charge-negative-balance.py
@@ -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.")
diff --git a/uncloud_pay/management/commands/import-vat-rates.py b/uncloud_pay/management/commands/import-vat-rates.py
index 46848cd..a741740 100644
--- a/uncloud_pay/management/commands/import-vat-rates.py
+++ b/uncloud_pay/management/commands/import-vat-rates.py
@@ -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!')
diff --git a/uncloud_pay/migrations/0012_auto_20210630_0742.py b/uncloud_pay/migrations/0012_auto_20210630_0742.py
new file mode 100644
index 0000000..45e3dfe
--- /dev/null
+++ b/uncloud_pay/migrations/0012_auto_20210630_0742.py
@@ -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),
+ ),
+ ]
diff --git a/uncloud_pay/migrations/0013_alter_billingaddress_owner.py b/uncloud_pay/migrations/0013_alter_billingaddress_owner.py
new file mode 100644
index 0000000..7597129
--- /dev/null
+++ b/uncloud_pay/migrations/0013_alter_billingaddress_owner.py
@@ -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),
+ ),
+ ]
diff --git a/uncloud_pay/migrations/0014_auto_20210703_1747.py b/uncloud_pay/migrations/0014_auto_20210703_1747.py
new file mode 100644
index 0000000..1c004d0
--- /dev/null
+++ b/uncloud_pay/migrations/0014_auto_20210703_1747.py
@@ -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),
+ ),
+ ]
diff --git a/uncloud_pay/migrations/0015_auto_20210705_0849.py b/uncloud_pay/migrations/0015_auto_20210705_0849.py
new file mode 100644
index 0000000..dfb6d80
--- /dev/null
+++ b/uncloud_pay/migrations/0015_auto_20210705_0849.py
@@ -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),
+ ),
+ ]
diff --git a/uncloud_pay/migrations/0016_pricingplan.py b/uncloud_pay/migrations/0016_pricingplan.py
new file mode 100644
index 0000000..505c141
--- /dev/null
+++ b/uncloud_pay/migrations/0016_pricingplan.py
@@ -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)),
+ ],
+ ),
+ ]
diff --git a/uncloud_pay/migrations/0017_auto_20210706_1728.py b/uncloud_pay/migrations/0017_auto_20210706_1728.py
new file mode 100644
index 0000000..1571b10
--- /dev/null
+++ b/uncloud_pay/migrations/0017_auto_20210706_1728.py
@@ -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',
+ ),
+ ]
diff --git a/uncloud_pay/migrations/0018_payment.py b/uncloud_pay/migrations/0018_payment.py
new file mode 100644
index 0000000..47d6e3a
--- /dev/null
+++ b/uncloud_pay/migrations/0018_payment.py
@@ -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)),
+ ],
+ ),
+ ]
diff --git a/uncloud_pay/migrations/0019_order_pricing_plan.py b/uncloud_pay/migrations/0019_order_pricing_plan.py
new file mode 100644
index 0000000..5392ce6
--- /dev/null
+++ b/uncloud_pay/migrations/0019_order_pricing_plan.py
@@ -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'),
+ ),
+ ]
diff --git a/uncloud_pay/migrations/0020_rename_is_final_bill_is_closed.py b/uncloud_pay/migrations/0020_rename_is_final_bill_is_closed.py
new file mode 100644
index 0000000..f3419eb
--- /dev/null
+++ b/uncloud_pay/migrations/0020_rename_is_final_bill_is_closed.py
@@ -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',
+ ),
+ ]
diff --git a/uncloud_pay/migrations/0021_auto_20210709_0914.py b/uncloud_pay/migrations/0021_auto_20210709_0914.py
new file mode 100644
index 0000000..66e3dcb
--- /dev/null
+++ b/uncloud_pay/migrations/0021_auto_20210709_0914.py
@@ -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',
+ ),
+ ]
diff --git a/uncloud_pay/migrations/0022_remove_order_status.py b/uncloud_pay/migrations/0022_remove_order_status.py
new file mode 100644
index 0000000..2b51be8
--- /dev/null
+++ b/uncloud_pay/migrations/0022_remove_order_status.py
@@ -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',
+ ),
+ ]
diff --git a/uncloud_pay/models.py b/uncloud_pay/models.py
index 49e8da2..a4cf007 100644
--- a/uncloud_pay/models.py
+++ b/uncloud_pay/models.py
@@ -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):
diff --git a/uncloud_pay/selectors.py b/uncloud_pay/selectors.py
index 2a5ad4a..a634e30 100644
--- a/uncloud_pay/selectors.py
+++ b/uncloud_pay/selectors.py
@@ -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()
diff --git a/uncloud_pay/serializers.py b/uncloud_pay/serializers.py
index b5de192..4ea4104 100644
--- a/uncloud_pay/serializers.py
+++ b/uncloud_pay/serializers.py
@@ -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']
diff --git a/uncloud_pay/services.py b/uncloud_pay/services.py
index ed97c39..84a7c8d 100644
--- a/uncloud_pay/services.py
+++ b/uncloud_pay/services.py
@@ -1,3 +1,5 @@
+import datetime
+from calendar import monthrange
from django.utils import timezone
diff --git a/uncloud_pay/stripe.py b/uncloud_pay/stripe.py
index ed95c82..a59456e 100644
--- a/uncloud_pay/stripe.py
+++ b/uncloud_pay/stripe.py
@@ -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
diff --git a/uncloud_pay/tasks.py b/uncloud_pay/tasks.py
deleted file mode 100644
index c372366..0000000
--- a/uncloud_pay/tasks.py
+++ /dev/null
@@ -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
diff --git a/uncloud_pay/templates/uncloud_pay/register_stripe.html b/uncloud_pay/templates/uncloud_pay/register_stripe.html
index 9613701..0eed76a 100644
--- a/uncloud_pay/templates/uncloud_pay/register_stripe.html
+++ b/uncloud_pay/templates/uncloud_pay/register_stripe.html
@@ -1,12 +1,11 @@
{% extends 'uncloud/base.html' %}
-
{% block bootstrap5_extra_head %}
+
{% endblock %}
-
{% block bootstrap5_content %}
-
+ {% csrf_token %}
Register Credit Card with Stripe
@@ -18,10 +17,15 @@
-
-
-
Save
-
+
+
+
The card will be registered with stripe.
@@ -32,44 +36,43 @@
{% endblock %}
diff --git a/uncloud_pay/tests.py b/uncloud_pay/tests.py
index c07c83b..0fde03d 100644
--- a/uncloud_pay/tests.py
+++ b/uncloud_pay/tests.py
@@ -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()
diff --git a/uncloud_pay/utils.py b/uncloud_pay/utils.py
new file mode 100644
index 0000000..c80ae3a
--- /dev/null
+++ b/uncloud_pay/utils.py
@@ -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)
+
+
+
diff --git a/uncloud_pay/views.py b/uncloud_pay/views.py
index d3a104b..e90dda1 100644
--- a/uncloud_pay/views.py
+++ b/uncloud_pay/views.py
@@ -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)