Merge branch 'master' into 'master'

- Implement a complete cycle for buying a Matrix Chat Host

See merge request uncloud/uncloud!11
This commit is contained in:
nico14571 2021-07-19 16:36:11 +02:00
commit 8287e73f6b
81 changed files with 5079 additions and 810 deletions

2
.gitignore vendored
View file

@ -22,6 +22,6 @@ uncloud/version.py
build/
venv/
dist/
.history/
*.iso
*.sqlite3

View file

@ -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

View file

4
matrixhosting/admin.py Normal file
View file

@ -0,0 +1,4 @@
from django.contrib import admin
from .models import VMInstance
admin.site.register(VMInstance)

9
matrixhosting/apps.py Normal file
View file

@ -0,0 +1,9 @@
from django.apps import AppConfig
class MatrixhostingConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'matrixhosting'
def ready(self):
from . import signals

48
matrixhosting/forms.py Normal file
View file

@ -0,0 +1,48 @@
import tldextract
from django import forms
from django.forms import ModelForm
from django.utils.translation import get_language, ugettext_lazy as _
from django.core.exceptions import ValidationError
from .validators import domain_name_validator
from uncloud_pay.models import BillingAddress
class DomainNameField(forms.CharField):
description = 'Domain name form field'
default_validators = [domain_name_validator, ]
def __init__(self, *args, **kwargs):
super(DomainNameField, self).__init__(*args, **kwargs)
class RequestHostedVMForm(forms.Form):
cores = forms.IntegerField(label='CPU', min_value=1, max_value=48, initial=1)
memory = forms.IntegerField(label='RAM', min_value=2, max_value=200, initial=2)
storage = forms.IntegerField(label='Storage', min_value=100, max_value=10000, initial=100)
matrix_domain = DomainNameField(required=True)
homeserver_domain = DomainNameField(required=True)
webclient_domain = DomainNameField(required=True)
is_open_registration = forms.BooleanField(required=False, initial=False)
pricing_name = forms.CharField(required=True)
def clean(self):
homeserver_domain = self.cleaned_data.get('homeserver_domain', False)
webclient_domain = self.cleaned_data.get('webclient_domain', False)
if homeserver_domain and webclient_domain:
# Homserver-Domain and Webclient-Domain cannot be below the same second level domain (i.e. homeserver.abc.ch and webclient.def.cloud are ok,
# homeserver.abc.ch and webclient.abc.ch are not ok
homeserver_base = tldextract.extract(homeserver_domain).domain
webclient_base = tldextract.extract(webclient_domain).domain
if homeserver_base == webclient_base:
self._errors['webclient_domain'] = self.error_class([
'Homserver-Domain and Webclient-Domain cannot be below the same second level domain'])
return self.cleaned_data
class BillingAddressForm(ModelForm):
class Meta:
model = BillingAddress
fields = ['full_name', 'street',
'city', 'postal_code', 'country', 'vat_number', 'active', 'owner']

View file

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

View file

@ -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',
),
]

View file

@ -0,0 +1,33 @@
# Generated by Django 3.2.4 on 2021-07-03 15:23
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('matrixhosting', '0002_rename_vmpricing_matrixvmpricing'),
]
operations = [
migrations.AlterField(
model_name='matrixvmpricing',
name='cores_unit_price',
field=models.DecimalField(decimal_places=2, default=0, max_digits=7),
),
migrations.AlterField(
model_name='matrixvmpricing',
name='ram_unit_price',
field=models.DecimalField(decimal_places=2, default=0, max_digits=7),
),
migrations.AlterField(
model_name='matrixvmpricing',
name='set_up_fees',
field=models.DecimalField(decimal_places=2, default=0, max_digits=7),
),
migrations.AlterField(
model_name='matrixvmpricing',
name='storage_unit_price',
field=models.DecimalField(decimal_places=2, default=0, max_digits=7),
),
]

View file

@ -0,0 +1,43 @@
# Generated by Django 3.2.4 on 2021-07-05 06:52
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('uncloud_pay', '0014_auto_20210703_1747'),
('matrixhosting', '0003_auto_20210703_1523'),
]
operations = [
migrations.CreateModel(
name='VMSpecs',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('cores', models.IntegerField(default=1)),
('memory', models.IntegerField(default=2)),
('storage', models.IntegerField(default=100)),
('matrix_domain', models.CharField(max_length=255)),
('homeserver_domain', models.CharField(max_length=255)),
('webclient_domain', models.CharField(max_length=255)),
('is_open_registration', models.BooleanField(default=False, null=True)),
],
),
migrations.CreateModel(
name='MatrixHostingOrder',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('vm_id', models.IntegerField(default=0)),
('created_at', models.DateTimeField(auto_now_add=True)),
('status', models.CharField(choices=[('draft', 'Draft'), ('declined', 'Declined'), ('approved', 'Approved')], default='draft', max_length=100)),
('stripe_charge_id', models.CharField(max_length=100, null=True)),
('price', models.FloatField()),
('billing_address', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='uncloud_pay.billingaddress')),
('customer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.stripecustomer')),
('specs', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, to='matrixhosting.vmspecs')),
('vm_pricing', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='matrixhosting.matrixvmpricing')),
],
),
]

View file

@ -0,0 +1,19 @@
# Generated by Django 3.2.4 on 2021-07-05 08:49
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('matrixhosting', '0004_matrixhostingorder_vmspecs'),
]
operations = [
migrations.DeleteModel(
name='MatrixHostingOrder',
),
migrations.DeleteModel(
name='VMSpecs',
),
]

View file

@ -0,0 +1,16 @@
# Generated by Django 3.2.4 on 2021-07-06 13:21
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('matrixhosting', '0005_auto_20210705_0849'),
]
operations = [
migrations.DeleteModel(
name='MatrixVMPricing',
),
]

View file

@ -0,0 +1,31 @@
# Generated by Django 3.2.4 on 2021-07-09 09:14
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
('uncloud_pay', '0021_auto_20210709_0914'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('matrixhosting', '0006_delete_matrixvmpricing'),
]
operations = [
migrations.CreateModel(
name='VMInstance',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('ip', models.TextField(default='')),
('config', models.JSONField()),
('creation_date', models.DateTimeField(auto_now_add=True)),
('termination_date', models.DateTimeField(blank=True, null=True)),
('order', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='instance_id', to='uncloud_pay.order')),
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
]

View file

@ -0,0 +1,17 @@
# Generated by Django 3.2.4 on 2021-07-10 14:29
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('matrixhosting', '0007_vminstance'),
]
operations = [
migrations.RemoveField(
model_name='vminstance',
name='ip',
),
]

View file

@ -0,0 +1,19 @@
# Generated by Django 3.2.4 on 2021-07-13 10:20
from django.db import migrations, models
import uuid
class Migration(migrations.Migration):
dependencies = [
('matrixhosting', '0008_remove_vminstance_ip'),
]
operations = [
migrations.AddField(
model_name='vminstance',
name='vm_id',
field=models.UUIDField(default=uuid.uuid4, editable=False, unique=True),
),
]

View file

77
matrixhosting/models.py Normal file
View file

@ -0,0 +1,77 @@
import logging
import uuid
import os
import sys
import gitlab
from jinja2 import Environment, FileSystemLoader
from django.db import models
from django.conf import settings
from django.contrib.auth import get_user_model
from django.template.loader import render_to_string
from uncloud_pay.models import Order
# Initialize logger.
logger = logging.getLogger(__name__)
class VMInstance(models.Model):
owner = models.ForeignKey(get_user_model(),
on_delete=models.CASCADE,
editable=True)
vm_id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
config = models.JSONField(null=False, blank=False)
order = models.OneToOneField(Order, on_delete=models.CASCADE, related_name='instance_id')
creation_date = models.DateTimeField(auto_now_add=True)
termination_date = models.DateTimeField(blank=True, null=True)
def save(self, *args, **kwargs):
# Read the deployment yaml file and render the template
# Then save it as new yaml file and push it to github repo
if 'test' in sys.argv:
return super().save(*args, **kwargs)
template_dir = os.path.join(os.path.dirname(__file__), 'yaml')
env = Environment(loader = FileSystemLoader(template_dir),autoescape = True)
tmpl = env.get_template('deployment.yaml.tmpl')
result = tmpl.render(
name=self.vm_id
)
gl = gitlab.Gitlab(settings.GITLAB_SERVER, oauth_token=settings.GITLAB_OAUTH_TOKEN)
project = gl.projects.get(settings.GITLAB_PROJECT_ID)
project.files.create({'file_path': settings.GITLAB_YAML_DIR + f'{self.vm_id}.yaml',
'branch': 'master',
'content': result,
'author_email': settings.GITLAB_AUTHOR_EMAIL,
'author_name': settings.GITLAB_AUTHOR_NAME,
'commit_message': f'Add New Deployment for {self.vm_id}'})
super().save(*args, **kwargs)
def delete(self, *args, **kwargs):
# Delete the deployment yaml file first then
# Then delete it
if 'test' in sys.argv:
return super().delete(*args, **kwargs)
gl = gitlab.Gitlab(settings.GITLAB_SERVER, oauth_token=settings.GITLAB_OAUTH_TOKEN)
project = gl.projects.get(settings.GITLAB_PROJECT_ID)
f_path = settings.GITLAB_YAML_DIR + f'{self.vm_id}.yaml'
file = project.files.get(file_path=f_path, ref='master')
if file:
project.files.delete(file_path=f_path,
commit_message=f'Delete {self.vm_id}', branch='master',
author_email=settings.GITLAB_AUTHOR_EMAIL,
author_name=settings.GITLAB_AUTHOR_NAME)
super().delete(*args, **kwargs)
def __str__(self):
return f"{self.id}-{self.order}"
def delete_for_bill(self, bill):
#TODO delete related instances
return True

View file

@ -0,0 +1,8 @@
from rest_framework import serializers
from .models import *
class VMInstanceSerializer(serializers.ModelSerializer):
class Meta:
model = VMInstance
fields = '__all__'

10
matrixhosting/signals.py Normal file
View file

@ -0,0 +1,10 @@
from matrixhosting.models import VMInstance
from uncloud_pay.models import Order
from django.db.models.signals import post_save
from django.dispatch import receiver
@receiver(post_save, sender=Order)
def create_instance(sender, instance, created, **kwargs):
machine = VMInstance.objects.filter(order=instance).first()
if not machine:
VMInstance.objects.create(owner=instance.owner, order=instance, config=instance.config)

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,618 @@
.navbar-transparent #logoWhite {
display: none;
}
.navbar-transparent #logoBlack {
display: block;
width: 220px;
}
.topnav .navbar-fixed-top .navbar-collapse {
max-height: 740px;
}
.navbar-default .navbar-header {
position: relative;
z-index: 1;
}
.navbar-right .highlights-dropdown .dropdown-menu {
left: 0 !important;
min-width: 155px;
margin-left: 15px;
padding: 0 5px 8px !important;
}
@media(min-width: 768px) {
.navbar-default .navbar-nav>li a,
.navbar-right .highlights-dropdown .dropdown-menu>li a {
font-weight: 300;
}
.navbar-right .highlights-dropdown .dropdown-menu {
border-width: 0 0 1px 0;
border-color: #e7e7e7;
box-shadow: -8px 14px 20px -5px rgba(77, 77, 77, 0.5);
}
}
.navbar-right .highlights-dropdown .dropdown-menu>li a {
font-size: 13px;
font-family: 'Lato', sans-serif;
padding: 1px 10px 1px 18px !important;
background: transparent;
color: #333;
}
.navbar-right .highlights-dropdown .dropdown-menu>li a:hover,
.navbar-right .highlights-dropdown .dropdown-menu>li a:focus,
.navbar-right .highlights-dropdown .dropdown-menu>li a:active {
background: transparent;
text-decoration: underline !important;
}
.un-icon {
width: 15px;
height: 15px;
opacity: 0.5;
margin-top: -1px;
}
/***** DCL payment page **********/
.dcl-order-container {
font-weight: 300;
}
.dcl-place-order-text {
color: #808080;
}
.card-warning-content {
font-weight: 300;
border: 1px solid #a1a1a1;
border-radius: 3px;
padding: 5px;
margin-bottom: 15px;
}
.card-warning-error {
border: 1px solid #EB4D5C;
color: #EB4D5C;
}
.card-warning-addtional-margin {
margin-top: 15px;
}
.card-cvc-element label {
padding-left: 10px;
}
.card-element {
margin-bottom: 10px;
}
.card-element label {
width: 100%;
margin-bottom: 0px;
}
.my-input {
border-bottom: 1px solid #ccc;
}
.card-cvc-element .my-input {
padding-left: 10px;
}
#card-errors {
clear: both;
padding: 0 0 10px;
color: #eb4d5c;
}
.credit-card-goup {
padding: 0;
}
@media (max-width: 767px) {
.card-expiry-element {
padding-right: 10px;
}
.card-cvc-element {
padding-left: 10px;
}
#billing-form .form-control {
box-shadow: none !important;
font-weight: 400;
}
}
@media (min-width: 1200px) {
.dcl-order-container {
width: 990px;
padding: 0 15px;
margin: 0 auto;
}
}
.footer-vm p.copyright {
margin-top: 4px;
}
.navbar-default .navbar-nav>.open>a,
.navbar-default .navbar-nav>.open>a:focus,
.navbar-default .navbar-nav>.open>a:hover,
.navbar-default .navbar-nav>.active>a,
.navbar-default .navbar-nav>.active>a:focus,
.navbar-default .navbar-nav>.active>a:hover {
background-color: transparent;
}
@media (max-width: 767px) {
.navbar-default .navbar-nav .open .dropdown-menu>.active a,
.navbar-default .navbar-nav .open .dropdown-menu>.active a:focus,
.navbar-default .navbar-nav .open .dropdown-menu>.active a:hover {
background-color: transparent;
}
}
/* bootstrap input box-shadow disable */
.has-error .form-control:focus,
.has-error .form-control:active,
.has-success .form-control:focus,
.has-success .form-control:active {
box-shadow: inset 0 0 1px rgba(0, 0, 0, 0.25);
}
.content-dashboard {
min-height: calc(100vh - 96px);
width: 100%;
margin: 0 auto;
max-width: 1120px;
}
@media (max-width: 767px) {
.content-dashboard {
padding: 0 15px;
}
}
@media (max-width: 575px) {
select {
width: 280px;
}
}
.btn:focus,
.btn:active:focus {
outline: 0;
}
/***********Styles for Model********************/
.modal-content {
border-radius: 0px;
font-family: Lato, "Helvetica Neue", Helvetica, Arial, sans-serif;
width: 100%;
float: left;
border-radius: 0;
font-weight: 300;
}
.modal-header {
min-height: 30px;
border-bottom: 0px solid #e5e5e5;
padding: 0px 15px;
width: 100%;
}
.modal-header .close {
font-size: 75px;
font-weight: 300;
margin-top: 0;
position: absolute;
top: 0;
right: 11px;
z-index: 10;
line-height: 60px;
}
.modal-header .close span {
display: block;
}
.modal-header .close:focus {
outline: 0;
}
.modal-body {
text-align: center;
width: 100%;
float: left;
padding: 0px 30px 15px 30px;
}
.modal-body .modal-icon i {
font-size: 80px;
font-weight: 100;
color: #999;
}
.modal-body .modal-icon {
margin-bottom: 15px;
}
.modal-title {
margin: 0;
line-height: 1.42857143;
font-size: 25px;
padding: 0;
font-weight: 300;
}
.modal-text {
padding-top: 5px;
font-size: 16px;
}
.modal-text p:not(:last-of-type) {
margin-bottom: 5px;
}
.modal-title+.modal-footer {
margin-top: 5px;
}
.modal-footer {
border-top: 0px solid #e5e5e5;
width: 100%;
float: left;
text-align: center;
padding: 15px 15px;
}
.modal {
text-align: center;
}
.modal-dialog {
display: inline-block;
text-align: left;
vertical-align: middle;
width: 40%;
margin: 15px auto;
}
@media (min-width: 768px) and (max-width: 991px) {
.modal-dialog {
width: 50%;
}
}
@media (max-width: 767px) {
.modal-dialog {
width: 95%;
}
}
@media(min-width: 576px) {
.modal:before {
content: '';
display: inline-block;
height: 100%;
vertical-align: middle;
margin-right: -4px;
}
}
/* ========= */
.btn-wide {
min-width: 100px;
}
.choice-btn {
min-width: 110px;
background-color: #3C5480;
color: #fff;
border: 2px solid #3C5480;
padding: 4px 10px;
transition: 0.3s all ease-out;
}
.choice-btn:focus,
.choice-btn:hover,
.choice-btn:active {
color: #3C5480;
background-color: #fff;
}
@media (max-width: 767px) {
.choice-btn {
margin-top: 15px;
}
}
.payment-container {
padding-top: 70px;
padding-bottom: 11%;
}
.last-p {
margin-bottom: 0;
}
.dcl-payment-section {
max-width: 391px;
margin: 0 auto 30px;
padding: 0 10px 30px;
border-bottom: 1px solid #edebeb;
height: 100%;
}
.dcl-payment-section hr {
margin-top: 15px;
margin-bottom: 15px;
}
.dcl-payment-section .top-hr {
margin-left: -10px;
}
.dcl-payment-section h3 {
font-weight: 600;
}
.dcl-payment-section p {
font-weight: 400;
}
.dcl-payment-section .card-warning-content {
padding: 8px 10px;
font-weight: 300;
}
.dcl-payment-order strong {
font-size: 17px;
}
.dcl-payment-order p {
font-weight: 300;
}
.dcl-payment-section .form-group {
margin-bottom: 10px;
}
.dcl-payment-section .form-control {
box-shadow: none;
padding: 6px 12px;
height: 32px;
}
.dcl-payment-user {
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
}
.dcl-payment-user h4 {
font-weight: 600;
font-size: 17px;
}
@media (min-width: 768px) {
.dcl-payment-grid {
display: flex;
align-items: stretch;
flex-wrap: wrap;
}
.dcl-payment-box {
width: 50%;
position: relative;
padding: 0 30px;
}
.dcl-payment-box:nth-child(2) {
order: 1;
}
.dcl-payment-box:nth-child(4) {
order: 2;
}
.dcl-payment-section {
padding-top: 15px;
padding-bottom: 15px;
margin-bottom: 0;
border-bottom-width: 5px;
}
.dcl-payment-box:nth-child(2n) .dcl-payment-section {
border-bottom: none;
}
.dcl-payment-box:nth-child(1):after,
.dcl-payment-box:nth-child(2):after {
content: ' ';
display: block;
background: #eee;
width: 1px;
position: absolute;
right: 0;
z-index: 2;
top: 20px;
bottom: 20px;
}
}
#virtual_machine_create_form {
padding: 15px 0;
}
.btn-vm-contact {
color: #fff;
background: #A3C0E2;
border: 2px solid #A3C0E2;
padding: 5px 25px;
font-size: 12px;
letter-spacing: 1.3px;
}
.btn-vm-contact:hover,
.btn-vm-contact:focus {
background: #fff;
color: #a3c0e2;
}
/* hosting-order */
.order-detail-container {
max-width: 600px;
margin: 100px auto 40px;
border: 1px solid #ccc;
padding: 30px 30px 20px;
color: #595959;
}
.order-detail-container .dashboard-title-thin {
margin-top: 0;
margin-left: -3px;
}
.order-detail-container .dashboard-title-thin .un-icon {
margin-top: -6px;
}
.order-detail-container .dashboard-container-head {
position: relative;
padding: 0;
margin-bottom: 38px;
}
.order-detail-container .order-details {
margin-bottom: 15px;
}
.order-detail-container h4 {
font-size: 16px;
font-weight: bold;
margin-bottom: 10px;
}
.order-detail-container p {
margin-bottom: 5px;
}
.order-detail-container hr {
margin: 15px 0;
}
.order-detail-container .thin-hr {
margin: 10px 0;
}
.order-detail-container .subtotal-price {
font-size: 16px;
}
.order-detail-container .subtotal-price .text-primary {
font-size: 17px;
}
.order-detail-container .total-price {
font-size: 18px;
line-height: 20px;
}
@media (max-width: 767px) {
.order-detail-container {
padding: 15px;
}
.order-confirm-btn {
text-align: center;
margin-top: 10px;
}
.order-detail-container .dashboard-container-options {
position: absolute;
top: 4px;
right: -4px;
}
.order-detail-container .dashboard-container-options .svg-img {
height: 16px;
width: 16px;
}
}
.order_detail_footer {
font-size: 9px;
letter-spacing: 1px;
color: #333333;
}
.order_detail_footer strong {
font-size: 11px;
}
.order_detail_footer small {
font-size: 8px;
}
.dashboard-title-thin {
font-weight: 300;
font-size: 32px;
}
.dashboard-title-thin .un-icon {
height: 34px;
margin-right: 5px;
margin-top: -2px;
width: 34px;
vertical-align: middle;
}
@media (max-width:767px) {
.dashboard-title-thin {
font-size: 22px;
}
.dashboard-title-thin .un-icon {
height: 22px;
width: 22px;
margin-top: -3px;
}
}
.locale_date {
opacity: 0;
}
.locale_date.done {
opacity: 1;
}
.btn-vm-back {
color: #fff;
background: #C4CEDA;
border: 2px solid #C4CEDA;
padding: 5px 25px;
font-size: 12px;
letter-spacing: 1.3px;
}
.btn-vm-back:hover,
.btn-vm-back:focus {
color: #fff;
background: #8da4c0;
border-color: #8da4c0;
}

View file

@ -0,0 +1,46 @@
(function($) {
"use strict"; // Start of use strict
$(document).ready(function() {
function fetch_pricing() {
var url = '/matrix/pricing/' + $('#pricing_name').val() + '/calculate/';
var cores = $('#cores').val();
var memory = $('#memory').val();
var storage = $('#storage').val();
$.ajax({
type: 'GET',
url: url,
data: { cores: cores, memory: memory, storage: storage},
dataType: 'json',
success: function (data) {
if (data && data['price']) {
$('#total').text(data['price']);
}
}
});
};
function incrementValue(e) {
var valueElement = $(e.target).parent().parent().find('input');
var step = $(valueElement).attr('step');
var min = parseInt($(valueElement).attr('min'));
var max = parseInt($(valueElement).attr('max'));
var new_value = 0;
if (e.data.inc == 1) {
new_value = Math.min(parseInt($(valueElement).val()) + parseInt(step) * e.data.inc, max);
} else {
new_value = Math.max(parseInt($(valueElement).val()) + parseInt(step) * e.data.inc, min);
}
$(valueElement).val(new_value);
fetch_pricing();
return false;
};
if ($('#pricing_name') != undefined) {
fetch_pricing();
}
$('.fa-plus-circle.right').bind('click', {inc: 1}, incrementValue);
$('.fa-minus-circle.left').bind('click', {inc: -1}, incrementValue);
});
})(jQuery);

View file

@ -0,0 +1,36 @@
$( document ).ready(function() {
var create_vm_form = $('#virtual_machine_create_form');
create_vm_form.submit(placeOrderPayment);
function placeOrderPayment(e) {
e.preventDefault();
$.ajax({
url: create_vm_form.attr('action'),
type: 'POST',
data: create_vm_form.serialize(),
init: function () {
ok_btn = $('#createvm-modal-done-btn');
close_btn = $('#createvm-modal-close-btn');
ok_btn.addClass('btn btn-success btn-ok btn-wide hide');
close_btn.addClass('btn btn-danger btn-ok btn-wide hide');
},
success: function (data) {
fa_icon = $('.modal-icon').find('.fa-cog');
modal_btn = $('#createvm-modal-done-btn');
if (data.error) {
// Display error.message in your UI.
modal_btn.attr('href', error_url).removeClass('visually-hidden');
fa_icon.attr('class', 'fa fa-close');
modal_btn.attr('class', '').addClass('btn btn-danger btn-ok btn-wide');
$('#createvm-modal-title').text("Error Occurred");
$('#createvm-modal-body').html(data.error.message);
} else {
// The payment has succeeded
// Display a success message
modal_btn.attr('href', data.redirect).removeClass('visually-hidden');
$('#createvm-modal-title').text("Order Succeeded");
$('#createvm-modal-body').html("Order has been added and the instance will be ready soon");
}
}
});
}
});

View file

@ -0,0 +1,204 @@
var cardBrandToPfClass = {
'visa': 'pf-visa',
'mastercard': 'pf-mastercard',
'amex': 'pf-american-express',
'discover': 'pf-discover',
'diners': 'pf-diners',
'jcb': 'pf-jcb',
'unknown': 'pf-credit-card'
};
function setBrandIcon(brand) {
var brandIconElement = document.getElementById('brand-icon');
var pfClass = 'pf-credit-card';
if (brand in cardBrandToPfClass) {
pfClass = cardBrandToPfClass[brand];
}
for (var i = brandIconElement.classList.length - 1; i >= 0; i--) {
brandIconElement.classList.remove(brandIconElement.classList[i]);
}
brandIconElement.classList.add('pf');
brandIconElement.classList.add(pfClass);
}
$(document).ready(function () {
$.ajaxSetup({
beforeSend: function (xhr, settings) {
function getCookie(name) {
var cookieValue = null;
if (document.cookie && document.cookie != '') {
var cookies = document.cookie.split(';');
for (var i = 0; i < cookies.length; i++) {
var cookie = jQuery.trim(cookies[i]);
// Does this cookie string begin with the name we want?
if (cookie.substring(0, name.length + 1) == (name + '=')) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
}
if (!(/^http:.*/.test(settings.url) || /^https:.*/.test(settings.url))) {
// Only send the token to relative URLs i.e. locally.
xhr.setRequestHeader("X-CSRFToken", getCookie('csrftoken'));
}
}
});
var hasCreditcard = window.hasCreditcard || false;
if (!hasCreditcard && window.stripeKey) {
var stripe = Stripe(window.stripeKey);
if (window.pm_id) {
} else {
var element_style = {
fonts: [{
family: 'lato-light',
src: 'url(https://cdn.jsdelivr.net/font-lato/2.0/Lato/Lato-Light.woff) format("woff2")'
}, {
family: 'lato-regular',
src: 'url(https://cdn.jsdelivr.net/font-lato/2.0/Lato/Lato-Regular.woff) format("woff2")'
}
],
locale: window.current_lan
};
var elements = stripe.elements(element_style);
var credit_card_text_style = {
base: {
iconColor: '#666EE8',
color: '#31325F',
lineHeight: '25px',
fontWeight: 300,
fontFamily: "'lato-light', sans-serif",
fontSize: '14px',
'::placeholder': {
color: '#777'
}
},
invalid: {
iconColor: '#eb4d5c',
color: '#eb4d5c',
lineHeight: '25px',
fontWeight: 300,
fontFamily: "'lato-regular', sans-serif",
fontSize: '14px',
'::placeholder': {
color: '#eb4d5c',
fontWeight: 400
}
}
};
var enter_ccard_text = "Enter your credit card number";
if (typeof window.enter_your_card_text !== 'undefined') {
enter_ccard_text = window.enter_your_card_text;
}
var cardNumberElement = elements.create('cardNumber', {
style: credit_card_text_style,
placeholder: enter_ccard_text
});
cardNumberElement.mount('#card-number-element');
var cardExpiryElement = elements.create('cardExpiry', {
style: credit_card_text_style
});
cardExpiryElement.mount('#card-expiry-element');
var cardCvcElement = elements.create('cardCvc', {
style: credit_card_text_style
});
cardCvcElement.mount('#card-cvc-element');
cardNumberElement.on('change', function (event) {
if (event.brand) {
setBrandIcon(event.brand);
}
});
}
}
function submitBillingForm(pmId) {
var billing_form = $('#billing-form');
billing_form.append('<input type="hidden" name="id_payment_method" value="' + pmId + '" />');
billing_form.submit();
}
var $form_new = $('#payment-form-new');
$form_new.submit(payWithPaymentIntent);
window.result = "";
window.card = "";
function payWithPaymentIntent(e) {
e.preventDefault();
function stripePMHandler(paymentMethod) {
// Insert the token ID into the form so it gets submitted to the server
console.log(paymentMethod);
$('#id_payment_method').val(paymentMethod.id);
submitBillingForm(paymentMethod.id);
}
stripe.createPaymentMethod({
type: 'card',
card: cardNumberElement,
})
.then(function(result) {
// Handle result.error or result.paymentMethod
window.result = result;
if(result.error) {
var errorElement = document.getElementById('card-errors');
errorElement.textContent = result.error.message;
} else {
console.log("created paymentMethod " + result.paymentMethod.id);
stripePMHandler(result.paymentMethod);
}
});
window.card = cardNumberElement;
}
/* Form validation */
$.validator.addMethod("month", function (value, element) {
return this.optional(element) || /^(01|02|03|04|05|06|07|08|09|10|11|12)$/.test(value);
}, "Please specify a valid 2-digit month.");
$.validator.addMethod("year", function (value, element) {
return this.optional(element) || /^[0-9]{2}$/.test(value);
}, "Please specify a valid 2-digit year.");
validator = $form_new.validate({
rules: {
cardNumber: {
required: true,
creditcard: true,
digits: true
},
expMonth: {
required: true,
month: true
},
expYear: {
required: true,
year: true
},
cvCode: {
required: true,
digits: true
}
},
highlight: function (element) {
$(element).closest('.form-control').removeClass('success').addClass('error');
},
unhighlight: function (element) {
$(element).closest('.form-control').removeClass('error').addClass('success');
},
errorPlacement: function (error, element) {
$(element).closest('.form-group').append(error);
}
});
$('.credit-card-info .btn.choice-btn').click(function () {
var id = this.dataset['id_card'];
$('#id_card').val(id);
submitBillingForm(id);
});
});

64
matrixhosting/tasks.py Normal file
View file

@ -0,0 +1,64 @@
import logging
from datetime import date, timedelta, timezone
from django.conf import settings
from django.template.loader import render_to_string
from django_q.tasks import async_task, schedule
from django_q.models import Schedule
from django.db.models import Q
from uncloud_pay.models import Bill, Payment
from uncloud_pay.selectors import has_enough_balance, get_balance_for_user
from .models import VMInstance
log = logging.getLogger(__name__)
def send_warning_email(bill, html_message):
schedule('django.core.mail.send_mail',
'Renewal Warning',
None,
settings.RENEWAL_FROM_EMAIL,
[bill.owner.email],
html_message,
schedule_type=Schedule.ONCE,
next_run=timezone.now() + timedelta(hours=1))
def charge_open_bills():
un_paid_bills = Bill.objects.filter(is_closed=False)
for bill in un_paid_bills:
date_diff = (date.today() - bill.due_date.date()).days
# If there is not enough money in the account 7 days before renewal, the system sends a warning
# If there is not enough money in the account 3 days before renewal, the system sends a 2nd warning
# If on renewal date there is not enough money in the account, delete the instance
if date_diff == 7:
if not has_enough_balance(bill.owner):
context = {'name': bill.owner.name, 'message': "You don't have enough balance for renewal... upload to your account _here"}
html_message = render_to_string('matrixhosting/emails/renewal_warning.html', context)
send_warning_email(bill, html_message)
elif date_diff == 3:
if not has_enough_balance(bill.owner):
context = {'name': bill.owner.name, 'message': "You don't have enough balance for renewal... Your instance will be deleted in 3 days"}
html_message = render_to_string('matrixhosting/emails/renewal_warning.html', context)
send_warning_email(bill, html_message)
elif date_diff <= 0:
if not has_enough_balance(bill.owner):
VMInstance.delete_for_bill(bill)
else:
try:
balance = get_balance_for_user(bill.owner)
if balance < 0:
payment = Payment.objects.create(owner=bill.owner, amount=balance, source='stripe')
if payment:
bill.close()
bill.close()
except Exception as e:
log.error(f"It seems that there is issue in payment for {bill.owner.name}", e)
# do nothing
def process_recurring_orders():
"""
Check for pending recurring and charge it and generate bills or send the customer warning
"""
Bill.create_bills_for_all_users()
def delete_instance(instance_id):
VMInstance.objects.delete(instance_id)

View file

@ -0,0 +1,60 @@
{% load static i18n %}
{% get_current_language as LANGUAGE_CODE %}
{% load bootstrap5 %}
<!DOCTYPE html>
<html lang="{{LANGUAGE_CODE}}">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="Matrix Hosting by ungleich">
<meta name="author" content="ungleich glarus ag">
<title>Matrix Hosting - {% block title %} made in Switzerland{% endblock %}</title>
<!-- Vendor CSS -->
<!-- Bootstrap Core CSS -->
{% bootstrap_css %}
<!-- Icon Fonts -->
<link href="{% static 'fontawesome_free/css/all.min.css' %}" rel="stylesheet" type="text/css">
<!-- Custom CSS -->
<link href="{% static 'matrixhosting/css/common.css' %}" rel="stylesheet">
{% block css_extra %}
{% endblock css_extra %}
<!-- External Fonts -->
<link href="//fonts.googleapis.com/css?family=Lato:300,400,600,700" rel="stylesheet" type="text/css">
<!-- HTML5 Shim and Respond.js IE8 support of HTML5 elements and media queries -->
<!-- WARNING: Respond.js doesn't work if you view the page via file:// -->
<!--[if lt IE 9]>
<script src="https://oss.maxcdn.com/libs/html5shiv/3.7.0/html5shiv.js"></script>
<script src="https://oss.maxcdn.com/libs/respond.js/1.4.2/respond.min.js"></script>
<![endif]-->
<!-- Google analytics -->
<!-- End Google Analytics -->
</head>
<body>
{% block navbar %}
{% include "matrixhosting/includes/_navbar.html" %}
{% endblock navbar %}
{% block content %}
{% endblock %}
{% include "matrixhosting/includes/_footer.html" %}
<!-- jQuery -->
<script src="https://code.jquery.com/jquery-3.6.0.min.js" integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4=" crossorigin="anonymous"></script>
<script src="{% static 'fontawesome_free/js/all.min.js' %}"></script>
<!-- Bootstrap Core JavaScript -->
{% bootstrap_javascript %}
<!-- Custom JS -->
<script src="{% static 'matrixhosting/js/main.js' %}"></script>
{% block js_extra %}
{% endblock js_extra %}
</body>
</html>

View file

@ -0,0 +1,127 @@
{% extends "matrixhosting/base.html" %} {% load static i18n %}
{% block content%}
<!-- Page Content -->
{% csrf_token %}
<div>
<div class="container">
<div class="row">
<div class="col-md-12">
<table class="table">
<thead>
<tr>
<th scope="col">#</th>
<th scope="col">Description</th>
<th scope="col">Starting At</th>
<th scope="col">Config</th>
<th scope="col">Pricing Plan</th>
<th scope="col">OneTime Price</th>
<th scope="col">Recurring Price</th>
<th scope="col">Ending At</th>
<th scope="col"></th>
</tr>
</thead>
<tbody>
{% for object in object_list %}
<tr data-id="{{object.id}}">
<th scope="row">{{ object.id }}</th>
<td>{{ object.description }}</td>
<td>{{ object.starting_date }}</td>
<td>{{ object.config }}</td>
<td>{{ object.pricing_plan}}</td>
<td>{{ object.one_time_price }}</td>
<td>{{ object.recurring_price }}</td>
<td>{{ object.ending_date }}</td>
{% if object.ending_date %}
<td></td>
{% else %}
<td>
<button
class="btn btn-danger btn-sm cancel-subscription"
type="submit"
name="action"
>
Cancel
</button>
</td>
{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
<div
class="modal fade"
tabindex="-1"
role="dialog"
aria-labelledby="mySmallModalLabel"
aria-hidden="true"
id="mi-modal"
>
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title" id="myModalLabel">Cancel Subscription</h4>
</div>
<div class="modal-body">
<p>
Are you sure that you want to cancel this subscription?. </p>
<p>
The instance will be active till the end date of the last bill and will be deleted
after that.
</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" id="modal-btn-yes">
Yes
</button>
<button type="button" class="btn btn-primary" id="modal-btn-no">
No
</button>
</div>
</div>
</div>
</div>
<div class="alert" role="alert" id="result"></div>
<!-- /.banner -->
{% endblock %}
{% block js_extra %}
<script type="text/javascript">
var modalConfirm = function (callback) {
$(".cancel-subscription").on("click", function (event) {
$('.selected').removeClass('selected');
$(event.target).parent().parent().addClass('selected');
$("#mi-modal").modal("show");
});
$("#modal-btn-yes").on("click", function () {
callback(true);
});
$("#modal-btn-no").on("click", function () {
callback(false);
$("#mi-modal").modal("hide");
});
};
modalConfirm(function (confirm) {
if (confirm) {
var selected_order = $('.selected').data('id');
$.ajax({
url: '{% url "matrix:dashboard" %}',
type: 'POST',
data: {'order_id': selected_order, 'csrfmiddlewaretoken': '{{ csrf_token }}',},
success: function (data) {
$("#mi-modal").modal("hide");
window.location.reload();
}
});
}
});
</script>
{% endblock %}

View file

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Renewal Warning</title>
</head>
<body>
hello <strong>{{name}},</strong>
{{message}}
</body>
</html>

View file

@ -0,0 +1,101 @@
{% load static i18n %}
<form id="order_form" method="POST" action="{% url 'matrix:index' %}" data-toggle="validator" role="form">
{% csrf_token %}
<div class="title">
<h3>{% trans "Matrix Chat hosting" %} </h3>
</div>
<div class="price">
<span id="total"> {{ matrix_vm_pricing.name }}</span>
<span>CHF/{% trans "month" %}</span>
<div class="price-text">
<p>
{% if matrix_vm_pricing.set_up_fees %}{{ matrix_vm_pricing.set_up_fees }} CHF Setup<br>{% endif %}
{% if matrix_vm_pricing.vat_inclusive %}{% trans "VAT included" %} <br>{% endif %}
{% if matrix_vm_pricing.discount_amount %}
{% trans "You save" %} {{ matrix_vm_pricing.discount_amount }} CHF
{% endif %}
</p>
</div>
</div>
<div class="descriptions">
<div class="description form-group">
<p>{% trans "Hosted in Switzerland" %}</p>
</div>
<div class="form-group">
<div class="description input">
<i class="fa fa-minus-circle left" data-minus="cores" aria-hidden="true"></i>
<input class="input-price select-number" type="number" min="1" max="48" id="cores" step="1" name="cores"
{% if form.cores.value != None %}value="{{ form.cores.value }}"{% endif %} data-error="{% trans 'Please enter a value in range 1 - 48.' %}" required>
<span> Core</span>
<i class="fa fa-plus-circle right" data-plus="cores" aria-hidden="true"></i>
</div>
<div class="help-block with-errors">
{% for message in messages %}
{% if 'cores' in message.tags %}
<ul class="list-unstyled">
<li>{{ message|safe }}</li>
</ul>
{% endif %}
{% endfor %}
</div>
</div>
<div class="form-group">
<div class="description input">
<i class="fa fa-minus-circle left" data-minus="memory" aria-hidden="true"></i>
<input id="memory" class="input-price select-number" type="number" min="2" max="200" name="memory"
{% if form.memory.value != None %}value="{{ form.memory.value }}"{% endif %} data-error="{% blocktrans with min_ram=min_ram %}Please enter a value in range {{min_ram}} - 200.{% endblocktrans %}" required step="1">
<span> GB RAM</span>
<i class="fa fa-plus-circle right" data-plus="memory" aria-hidden="true"></i>
</div>
<div class="help-block with-errors">
{% for message in messages %}
{% if 'memory' in message.tags %}
<ul class="list-unstyled"><li>
{{ message|safe }}
</li></ul>
{% endif %}
{% endfor %}
</div>
</div>
<div class="form-group">
<div class="description input">
<i class="fa fa-minus-circle left" data-minus="storage" aria-hidden="true"></i>
<input id="storage" class="input-price select-number" type="number" min="100" max="10000" step="100"
name="storage" {% if form.storage.value != None %}value="{{ form.storage.value }}"{% endif %} data-error="{% trans 'Please enter a value in range 100 - 10000.' %}" required>
<span>{% trans "GB Storage (SSD)" %}</span>
<i class="fa fa-plus-circle right" data-plus="storage" aria-hidden="true"></i>
</div>
<div class="help-block with-errors">
{% for message in messages %}
{% if 'storage' in message.tags %}
<ul class="list-unstyled"><li>
{{ message|safe }}
</li></ul>
{% endif %}
{% endfor %}
</div>
</div>
<div class="description domain select-configuration input form-group justify-center">
<input type="text" id="matrix_domain" name="matrix_domain" placeholder="Matrix Domain" {% if form.matrix_domain.value != None %}value="{{ form.matrix_domain.value }}"{% endif %}></input>
<p class="text-danger">{{ form.matrix_domain.errors }}</p>
</div>
<div class="description domain select-configuration input form-group justify-center">
<input type="text" id="homeserver_domain" name="homeserver_domain" placeholder="Homeserver Domain" {% if form.homeserver_domain.value != None %}value="{{ form.homeserver_domain.value }}"{% endif %} ></input>
<p class="text-danger">{{ form.homeserver_domain.errors }}</p>
</div>
<div class="description domain select-configuration input form-group justify-center">
<input type="text" id="webclient_domain" name="webclient_domain" placeholder="Webclient Domain" {% if form.webclient_domain.value != None %}value="{{ form.webclient_domain.value }}"{% endif %}></input>
<p class="text-danger">{{ form.webclient_domain.errors }}</p>
</div>
<div class="description input form-group">
<div class="fieldWrapper">
<span>Is open registration possible:</span>
{{ form.is_open_registration }}
</div>
</div>
</div>
<input type="hidden" name="pricing_name" id="pricing_name" value="{% if matrix_vm_pricing.name %}{{matrix_vm_pricing.name}}{% else %}unknown{% endif%}"></input>
<input type="submit" class="btn btn-primary" value="{% trans 'Continue' %}"></input>
</form>

View file

@ -0,0 +1,43 @@
{% load i18n %}
<form action="" id="payment-form-new" method="POST">
<input type="hidden" name="token"/>
<input type="hidden" name="id_card" id="id_card" value=""/>
<div class="group">
<div class="credit-card-goup">
<div class="card-element card-number-element">
<label>{%trans "Card Number" %}</label>
<div id="card-number-element" class="field my-input"></div>
</div>
<div class="row">
<div class="col-xs-5 card-element card-expiry-element">
<label>{%trans "Expiry Date" %}</label>
<div id="card-expiry-element" class="field my-input"></div>
</div>
<div class="col-xs-3 col-xs-offset-4 card-element card-cvc-element">
<label>{%trans "CVC" %}</label>
<div id="card-cvc-element" class="field my-input"></div>
</div>
</div>
<div class="card-element brand">
<label>{%trans "Card Type" %}</label>
<i class="pf pf-credit-card" id="brand-icon"></i>
</div>
</div>
</div>
<div id="card-errors"></div>
<div id='payment_error'>
{% for message in messages %}
{% if 'failed_payment' in message.tags or 'make_charge_error' in message.tags or 'error' in message.tags %}
<ul class="list-unstyled">
<li><p class="card-warning-content card-warning-error">{{ message|safe }}</p></li>
</ul>
{% endif %}
{% endfor %}
</div>
<div class="text-right">
<button class="btn btn-vm-contact btn-wide" type="submit" name="payment-form">{%trans "SUBMIT" %}</button>
</div>
<div style="display:none;">
<p class="payment-errors"></p>
</div>
</form>

View file

@ -0,0 +1,18 @@
{% load i18n %}
<footer>
<div class="container">
<ul class="list-inline">
<li>
<a class="url-init" href="">{% trans "Home" %}</a>
</li>
<li>
<a class="url-init" href="">{% trans "Contact" %}</a>
</li>
<li>
<a class="url-init" href="">{% trans "Terms of Service" %}</a>
</li>
</ul>
<p class="copyright text-muted small">Copyright &copy; ungleich glarus ag {% now "Y" %}. {% trans "All Rights Reserved" %}</p>
</div>
</footer>

View file

@ -0,0 +1,33 @@
{% load static i18n %}
{% get_current_language as LANGUAGE_CODE %}
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container-fluid">
<a class="navbar-brand" href="{% url 'uncloudindex' %}">Matrix Hosting</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNavDropdown" aria-controls="navbarNavDropdown" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNavDropdown">
<ul class="navbar-nav ms-auto">
<li class="nav-item">
<a class="nav-link active" aria-current="page" href="{% url 'matrix:index' %}">Home</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#">Features</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#">Pricing</a>
</li>
{% if not request.user.is_authenticated %}
<li class="nav-item">
<a class="nav-link" href="{% url 'account_login' %}">{% trans "Login" %}&nbsp;&nbsp;<i class="fa fa-sign-in-alt"></i></a>
</li>
{% else %}
<li class="nav-item">
<a class="nav-link" href="{% url 'matrix:dashboard' %}">{% trans "Dashboard" %}</a>
</li>
{% endif %}
</ul>
</div>
</div>
</nav>

View file

@ -0,0 +1,21 @@
{% extends "matrixhosting/base.html" %}
{% load static i18n %}
{% block content %}
<!-- Page Content -->
<div class="split-section pricing-section section-gradient" id="price">
<div class="container">
<div class="row">
<div class="col-md-6">
<div class="price-calc-section">
<div class="card">
{% include "matrixhosting/includes/_calculator_form.html" %}
</div>
</div>
</div>
</div>
</div>
</div>
<!-- /.banner -->
{% endblock %}

View file

@ -0,0 +1,268 @@
{% load static i18n %}
{% load bootstrap5 %}
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="Matrix Hosting by ungleich">
<meta name="author" content="ungleich glarus ag">
<title>Matrix Hosting - {% block title %} made in Switzerland{% endblock %}</title>
<!-- Vendor CSS -->
<!-- Bootstrap Core CSS -->
{% bootstrap_css %}
<!-- External Fonts -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/paymentfont/1.2.5/css/paymentfont.min.css"/>
<link href="//fonts.googleapis.com/css?family=Lato:300,400,600,700" rel="stylesheet" type="text/css">
<link href="{% static 'matrixhosting/css/hosting.css' %}" rel="stylesheet">
<!-- HTML5 Shim and Respond.js IE8 support of HTML5 elements and media queries -->
<!-- WARNING: Respond.js doesn't work if you view the page via file:// -->
<!--[if lt IE 9]>
<script src="https://oss.maxcdn.com/libs/html5shiv/3.7.0/html5shiv.js"></script>
<script src="https://oss.maxcdn.com/libs/respond.js/1.4.2/respond.min.js"></script>
<![endif]-->
</head>
<body>
<script>
window.paymentIntentSecret = "{{payment_intent_secret}}";
</script>
<div id="order-detail{{order.pk}}" class="order-detail-container">
{% if messages %}
<div class="alert alert-warning">
{% for message in messages %}
<span>{{ message }}</span>
{% endfor %}
</div>
{% endif %}
{% if not error %}
<div class="dashboard-container-head">
<h1 class="dashboard-title-thin">
{% blocktrans with page_header_text=page_header_text|default:"Order" %}{{page_header_text}}{% endblocktrans %}
</h1>
</div>
<div class="order-details">
<hr>
<div>
<address>
<h4>{% trans "Billed to" %}:</h4>
<p>
{% with request.session.billing_address_data as billing_address %}
{{billing_address.full_name}}<br>
{{billing_address.street}}, {{billing_address.postal_code}}<br>
{{billing_address.city}}, {{billing_address.country}}
{% if billing_address.vat_number %}
<br/>{% trans "VAT Number" %} {{billing_address.vat_number}}
{% if pricing.vat_country != "ch" and pricing.vat_validation_status != "not_needed" %}
{% if pricing.vat_validation_status == "verified" %}
<span class="fa fa-fw fa-check-circle" aria-hidden="true" title='{% trans "Your VAT number has been verified" %}'></span>
{% else %}
<span class="fa fa-fw fa-info-circle" aria-hidden="true" title='{% trans "Your VAT number is under validation. VAT will be adjusted, once the validation is complete." %}'></span>
{% endif %}
{% endif %}
{% endif %}
{% endwith %}
</p>
</address>
</div>
<hr>
<div>
<h4>{% trans "Payment method" %}:</h4>
<p>
{{card.brand|default:_('Credit Card')}} {% trans "ending in" %} ****{{card.last4}}<br>
{% trans "Expiry" %} {{card.exp_year}}/{{card.exp_month}}<br/>
{{request.user.email}}
</p>
</div>
<hr>
<div>
<h4>{% trans "Order summary" %}</h4>
<style>
@media screen and (max-width:400px){
.header-no-left-padding {
padding-left: 0 !important;
}
}
@media screen and (max-width:767px){
.cmf-ord-heading {
font-size: 11px;
}
.order-detail-container .order-details {
font-size: 13px;
}
}
@media screen and (max-width:367px){
.cmf-ord-heading {
font-size: 11px;
}
.order-detail-container .order-details {
font-size: 12px;
}
}
</style>
<p>
<strong>{% trans "Product" %}:</strong>&nbsp;
Matrix Chat Hosting
</p>
<div class="row">
<div class="col-sm-9">
<p>
<span>{% trans "Cores" %}: </span>
<strong class="pull-right">{{order.cores}}</strong>
</p>
<p>
<span>{% trans "Memory" %}: </span>
<strong class="pull-right">{{order.memory}} GB</strong>
</p>
<p>
<span>{% trans "Disk space" %}: </span>
<strong class="pull-right">{{order.storage}} GB</strong>
</p>
</div>
<div class="col-sm-12">
<hr class="thin-hr">
</div>
<div class="col-sm-9">
<p>
<strong class="text-uppercase">{% trans "Price Before VAT" %}</strong>
<strong class="pull-right">{{pricing.subtotal|floatformat:2}} CHF</strong>
</p>
</div>
<div class="col-sm-12">
<hr class="thin-hr">
</div>
<div class="col-sm-9">
<div class="row">
<div class="col-md-4 col-sm-4 col-xs-4">
<p><span></span></p>
</div>
<div class="col-md-3 col-sm-3 col-xs-4">
<p class="text-right"><strong class="cmf-ord-heading">{% trans "Pre VAT" %}</strong></p>
</div>
<div class="col-md-5 col-sm-5 col-xs-4 header-no-left-padding">
<p class="text-right"><strong class="cmf-ord-heading">{% trans "With VAT for" %} {{pricing.vat_country}} ({{pricing.vat_percent}}%)</strong></p>
</div>
</div>
<div class="row">
<div class="col-md-4 col-sm-4 col-xs-4">
<p><span>Subtotal</span></p>
</div>
<div class="col-md-3 col-sm-3 col-xs-4">
<p><span class="pull-right" >{{pricing.subtotal|floatformat:2}} CHF</span></p>
</div>
<div class="col-md-5 col-sm-5 col-xs-4">
<p><span class="pull-right">{{pricing.price_with_vat|floatformat:2}} CHF</span></p>
</div>
</div>
{% if pricing.discount.amount > 0 %}
<div class="row">
<div class="col-md-4 col-sm-4 col-xs-4">
<p><span>{{pricing.discount.name}}</span></p>
</div>
<div class="col-md-3 col-sm-3 col-xs-4">
<p><span class="pull-right">-{{pricing.discount.amount|floatformat:2}} CHF</span></p>
</div>
<div class="col-md-5 col-sm-5 col-xs-4">
<p><span class="pull-right">-{{pricing.discount.amount_with_vat|floatformat:2}} CHF</span></p>
</div>
</div>
{% endif %}
</div>
<div class="col-sm-12">
<hr class="thin-hr">
</div>
<div class="col-sm-9">
<div class="row">
<div class="col-md-4 col-sm-4 col-xs-4">
<p><strong>Total</strong></p>
</div>
<div class="col-md-3 col-sm-3 col-xs-4">
<p><strong class="pull-right">{{pricing.subtotal_after_discount|floatformat:2}} CHF</strong></p>
</div>
<div class="col-md-5 col-sm-5 col-xs-4">
<p><strong class="pull-right">{{pricing.price_after_discount_with_vat|floatformat:2}} CHF</strong></p>
</div>
</div>
</div>
<div class="col-sm-12">
<hr class="thin-hr">
</div>
<div class="col-sm-9">
<strong class="text-uppercase align-center">{% trans "Your Price in Total" %}</strong>
<strong class="total-price pull-right">{{pricing.total_price|floatformat:2}} CHF</strong>
</div>
</div>
</div>
<hr class="thin-hr">
</div>
<form id="virtual_machine_create_form" action="{% url 'matrix:order_details' %}" method="POST">
{% csrf_token %}
<div class="row">
<div class="col-sm-8">
<div class="dcl-place-order-text">{% blocktrans with vm_total_price=vm.total_price|floatformat:2 %}By clicking "Place order" you agree to our <a href="">Terms of Service</a> and this plan will charge your credit card account with {{ vm_total_price }} CHF/month{% endblocktrans %}.</div>
</div>
<div class="col-sm-4 order-confirm-btn text-right">
<button class="btn choice-btn" id="btn-create-vm" data-bs-toggle="modal" data-bs-target="#createvm-modal">
{% trans "Place order" %}
</button>
</div>
</div>
</form>
{% endif %}
</div>
<!-- Create VM Modal -->
<div class="modal fade" id="createvm-modal" tabindex="-1" role="dialog"
aria-hidden="true" data-backdrop="static" data-keyboard="false">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
</div>
<div class="modal-body">
<div class="modal-icon">
<i class="fa fa-cog fa-spin fa-3x fa-fw"></i>
<span class="sr-only">{% trans "Processing..." %}</span>
</div>
<h4 class="modal-title" id="createvm-modal-title"></h4>
<div class="modal-text" id="createvm-modal-body">
{% trans "Hold tight, we are processing your request" %}
</div>
<div class="modal-footer">
<a id="createvm-modal-done-btn" class="btn btn-success btn-ok btn-wide visually-hidden" href="">{% trans "OK" %}</a>
<button id="createvm-modal-close-btn" type="button" class="btn btn-danger btn-ok btn-wide visually-hidden" data-dismiss="modal" aria-label="create-vm-close">{% trans "Close" %}</button>
</div>
</div>
</div>
</div>
</div>
<!-- / Create VM Modal -->
<script type="text/javascript">
var create_vm_error_message = 'Some problem encountered. Please try again later';
var pm_id = '{{id_payment_method}}';
var error_url = '{{ error_msg.redirect }}';
var success_url = '{{ success_msg.redirect }}';
window.stripeKey = "{{stripe_key}}";
window.isSubscription = ("{{is_subscription}}" === 'true');
</script>
<!-- jQuery -->
<script src="https://js.stripe.com/v3/"></script>
<script src="https://code.jquery.com/jquery-3.6.0.min.js" integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4=" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery-validate/1.19.3/jquery.validate.min.js"></script>
<script src="{% static 'fontawesome_free/js/all.min.js' %}"></script>
<!-- Bootstrap Core JavaScript -->
{% bootstrap_javascript %}
<!-- Custom JS -->
<script type="text/javascript" src="{% static 'matrixhosting/js/order.js' %}"></script>
</body>
</html>

View file

@ -0,0 +1,169 @@
{% load static i18n %}
{% load bootstrap5 %}
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="Matrix Hosting by ungleich">
<meta name="author" content="ungleich glarus ag">
<title>Matrix Hosting - {% block title %} made in Switzerland{% endblock %}</title>
<!-- Vendor CSS -->
<!-- Bootstrap Core CSS -->
{% bootstrap_css %}
<!-- External Fonts -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/paymentfont/1.2.5/css/paymentfont.min.css"/>
<link href="//fonts.googleapis.com/css?family=Lato:300,400,600,700" rel="stylesheet" type="text/css">
<link href="{% static 'matrixhosting/css/hosting.css' %}" rel="stylesheet">
<!-- HTML5 Shim and Respond.js IE8 support of HTML5 elements and media queries -->
<!-- WARNING: Respond.js doesn't work if you view the page via file:// -->
<!--[if lt IE 9]>
<script src="https://oss.maxcdn.com/libs/html5shiv/3.7.0/html5shiv.js"></script>
<script src="https://oss.maxcdn.com/libs/respond.js/1.4.2/respond.min.js"></script>
<![endif]-->
</head>
<body>
<div class="container">
<div class="row">
<div class="col">
<div class="row">
<div class="dcl-payment-section">
<h3>{%trans "Your Order" %}</h3>
<hr class="top-hr">
<div class="dcl-payment-order">
<p>{% trans "Cores"%} <strong class="float-end">{{request.session.order.cores|floatformat}}</strong></p>
<hr>
<p>{% trans "Memory"%} <strong class="float-end">{{request.session.order.memory|floatformat}} GB</strong></p>
<hr>
<p>{% trans "Disk space"%} <strong class="float-end">{{request.session.order.storage|floatformat}} GB</strong></p>
<hr>
<p>
<strong>{%trans "Total" %}</strong>&nbsp;&nbsp;
<small>
({% if matrix_vm_pricing.vat_inclusive %}{%trans "including VAT" %}{% else %}{%trans "excluding VAT" %}{% endif %})
</small>
<strong class="float-end">{{request.session.order.subtotal|floatformat}} CHF / {% trans "Month" %}</strong>
</p>
<hr>
{% if matrix_vm_pricing.discount_amount %}
<p class="mb-0">
<strong>{{ request.session.order.discount.name }}</strong>&nbsp;&nbsp;
<strong class="float-end text-success">- {{ request.session.order.discount.amount }} CHF / {% trans "Month" %}</strong>
</p>
{% endif %}
</div>
</div>
<div class="row">
<div class="dcl-payment-section">
<h2><b>{%trans "Billing Address"%}</b></h2>
<hr class="top-hr">
{% for message in messages %}
{% if 'vat_error' in message.tags %}
<ul class="list-unstyled"><li>
{{ message|safe }}
</li></ul>
{% endif %}
{% endfor %}
<form role="form" id="billing-form" method="post" action="" novalidate>
{% csrf_token %}
{% for field in billing_address_form %}
{% if field.html_name in 'active,owner' %}
{{ field.as_hidden }}
{%else %}
{% bootstrap_field field show_label=False type='fields'%}
{% endif %}
{% endfor %}
</form>
</div>
</div>
</div>
</div>
<div class="col">
<div class="dcl-payment-section">
{% with cards_len=cards|length %}
<h3><b>{%trans "Credit Card"%}</b></h3>
<hr class="top-hr">
<p>
{% if cards_len > 0 %}
{% blocktrans %}Please select one of the cards that you used before or fill in your credit card information below. We are using <a href="https://stripe.com" target="_blank">Stripe</a> for payment and do not store your information in our database.{% endblocktrans %}
{% else %}
{% blocktrans %}Please fill in your credit card information below. We are using <a href="https://stripe.com" target="_blank">Stripe</a> for payment and do not store your information in our database.{% endblocktrans %}
{% endif %}
</p>
<div>
{% for card in cards %}
<div class="credit-card-info">
<div class="col-xs-6 no-padding">
<h5 class="billing-head">{% trans "Credit Card" %}</h5>
<h5 class="membership-lead">{% trans "Last" %} 4: ***** {{card.last4}}</h5>
<h5 class="membership-lead">{% trans "Type" %}: {{card.brand}}</h5>
<h5 class="membership-lead">{% trans "Expiry" %}: {{card.month}}/{{card.year}}</h5>
</div>
<div class="col-xs-6 text-right align-bottom">
<a class="btn choice-btn choice-btn-faded" href="#" data-id_card="{{card.id}}">{% trans "SELECT" %}</a>
</div>
</div>
{% endfor %}
{% if cards_len > 0 %}
<div class="new-card-head">
<div class="row">
<div class="col-xs-6">
<h4>{% trans "Add a new credit card" %}</h4>
</div>
<div class="col-xs-6 text-right new-card-button-margin">
<button data-bs-toggle="collapse" data-bs-target="#newcard" class="btn choice-btn">
<span class="fa fa-plus"></span>&nbsp;&nbsp;{% trans "NEW CARD" %}
</button>
</div>
</div>
</div>
<div id="newcard" class="collapse">
<hr class="thick-hr">
<div class="card-details-box">
<h3>{%trans "New Credit Card" %}</h3>
<hr>
{% include "matrixhosting/includes/_card.html" %}
</div>
</div>
{% else%}
{% include "matrixhosting/includes/_card.html" %}
{% endif %}
</div>
{% endwith %}
</div>
</div>
</div>
</div>
{% if stripe_key %}
{% get_current_language as LANGUAGE_CODE %}
<script type="text/javascript">
window.processing_text = '{%trans "Processing" %}';
window.enter_your_card_text = '{%trans "Enter your credit card number" %}';
(function () {
window.stripeKey = "{{stripe_key}}";
window.current_lan = "{{LANGUAGE_CODE}}";
})();
</script>
{%endif%}
<!-- jQuery -->
<script src="https://js.stripe.com/v3/"></script>
<script src="https://code.jquery.com/jquery-3.6.0.min.js" integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4=" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery-validate/1.19.3/jquery.validate.min.js"></script>
<script src="{% static 'fontawesome_free/js/all.min.js' %}"></script>
<!-- Bootstrap Core JavaScript -->
{% bootstrap_javascript %}
<!-- Custom JS -->
<script type="text/javascript" src="{% static 'matrixhosting/js/payment.js' %}"></script>
</body>
</html>

67
matrixhosting/tests.py Normal file
View file

@ -0,0 +1,67 @@
import datetime
import json
from django.test import TestCase
from django.contrib.auth import get_user_model
from django.utils import timezone
from .models import VMInstance
from uncloud_pay.models import Order, PricingPlan, BillingAddress, Product, RecurringPeriod
vm_product_config = {
'features': {
'cores':
{ 'min': 1,
'max': 48
},
'ram_gb':
{ 'min': 2,
'max': 200
},
},
}
class VMInstanceTestCase(TestCase):
def setUp(self):
RecurringPeriod.populate_db_defaults()
self.user = get_user_model().objects.create(
username='random_user',
email='jane.random@domain.tld')
self.config = json.dumps({
'cores': 1,
'memory': 2,
'storage': 100,
'homeserver_domain': '',
'webclient_domain': '',
'matrix_domain': '',
})
self.pricing_plan = PricingPlan.objects.create(name="PricingSample", set_up_fees=35, cores_unit_price=3,
ram_unit_price=4, storage_unit_price=0.02)
self.ba = BillingAddress.objects.create(
owner=self.user,
organization = 'Test org',
street="unknown",
city="unknown",
postal_code="somewhere else",
active=True)
self.product = Product.objects.create(name="Testproduct",
description="Only for testing",
config=vm_product_config)
self.default_recurring_period = RecurringPeriod.objects.get(name="Per 30 days")
self.product.recurring_periods.add(self.default_recurring_period,
through_defaults= { 'is_default': True })
def test_create_matrix_vm(self):
order = Order.objects.create(owner=self.user,
recurring_period=self.default_recurring_period,
billing_address=self.ba,
pricing_plan = self.pricing_plan,
product=self.product,
config=self.config)
instances = VMInstance.objects.filter(order=order)
self.assertEqual(len(instances), 1)

15
matrixhosting/urls.py Normal file
View file

@ -0,0 +1,15 @@
from django.urls import path, include
from django.conf import settings
from django.conf.urls.static import static
from .views import IndexView, PricingView, OrderPaymentView, OrderDetailsView, Dashboard
app_name = 'matrixhosting'
urlpatterns = [
path('pricing/<slug:name>/calculate/', PricingView.as_view(), name='pricing_calculator'),
path('payment/', OrderPaymentView.as_view(), name='payment'),
path('order/details/', OrderDetailsView.as_view(), name='order_details'),
path('dashboard/', Dashboard.as_view(), name='dashboard'),
path('', IndexView.as_view(), name='index'),
]

View file

@ -0,0 +1,34 @@
from django.core.validators import RegexValidator
def _validator():
ul = '\u00a1-\uffff' # unicode letters range (must not be a raw string)
# IP patterns
ipv4_re = r'(?:25[0-5]|2[0-4]\d|[0-1]?\d?\d)(?:\.(?:25[0-5]|2[0-4]\d|[0-1]?\d?\d)){3}'
ipv6_re = r'\[[0-9a-f:\.]+\]' # (simple regex, validated later)
# Host patterns
hostname_re = r'[a-z' + ul + \
r'0-9](?:[a-z' + ul + r'0-9-]{0,61}[a-z' + ul + r'0-9])?'
# Max length for domain name labels is 63 characters per RFC 1034 sec. 3.1
domain_re = r'(?:\.(?!-)[a-z' + ul + r'0-9-]{1,63}(?<!-))*'
tld_re = (
r'\.' # dot
r'(?!-)' # can't start with a dash
r'(?:[a-z' + ul + '-]{2,63}' # domain label
r'|xn--[a-z0-9]{1,59})' # or punycode label
r'(?<!-)' # can't end with a dash
r'\.?' # may have a trailing dot
r'/?'
)
host_re = '(' + hostname_re + domain_re + tld_re + ')'
regex = (
r'(?:' + ipv4_re + '|' + ipv6_re + '|' + host_re + ')'
r'(?::\d{2,5})?' # port
r'\Z')
return RegexValidator(regex, message='Enter a valid Domain (Not a URL)', code='invalid_domain')
domain_name_validator = _validator()

301
matrixhosting/views.py Normal file
View file

@ -0,0 +1,301 @@
import logging
import json
from django.shortcuts import redirect, render
from django.contrib import messages
from django.utils.translation import get_language, ugettext_lazy as _
from django.contrib.auth.decorators import login_required
from django.views.decorators.cache import cache_control
from django.utils.decorators import method_decorator
from django.views import View
from django.views.generic import FormView, DetailView
from django.views.generic.list import ListView
from matrixhosting.forms import RequestHostedVMForm, BillingAddressForm
from django.urls import reverse
from django.conf import settings
from django.http import (
HttpResponseRedirect, JsonResponse
)
from rest_framework import viewsets, permissions
from uncloud_pay.models import PricingPlan
from uncloud_pay.utils import get_order_total_with_vat
from uncloud_pay.models import *
from uncloud_pay.utils import validate_vat_number
from uncloud_pay.selectors import get_billing_address_for_user
import uncloud_pay.stripe as uncloud_stripe
from .models import VMInstance
from .serializers import *
logger = logging.getLogger(__name__)
class PricingView(View):
def get(self, request, **args):
subtotal, subtotal_after_discount, price_after_discount_with_vat, vat, vat_percent, discount = get_order_total_with_vat(
request.GET.get('cores'),
request.GET.get('memory'),
request.GET.get('storage'),
pricing_name = args['name']
)
return JsonResponse({'subtotal': subtotal})
class IndexView(FormView):
template_name = "matrixhosting/index.html"
form_class = RequestHostedVMForm
success_url = "/matrixhosting#requestform"
success_message = "Thank you, we will contact you as soon as possible"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['matrix_vm_pricing'] = PricingPlan.get_default_pricing()
return context
def form_valid(self, form):
self.request.session['order'] = form.cleaned_data
subtotal, subtotal_with_discount, total, vat, vat_percent, discount = get_order_total_with_vat(
form.cleaned_data['cores'],
form.cleaned_data['memory'],
form.cleaned_data['storage'],
form.cleaned_data['pricing_name'],
False
)
self.request.session['pricing'] = {'name': form.cleaned_data['pricing_name'],
'subtotal': subtotal, 'vat': vat,
'vat_percent': vat_percent, 'discount': discount}
return HttpResponseRedirect(reverse('matrix:payment'))
class OrderPaymentView(FormView):
template_name = 'matrixhosting/payment.html'
success_url = 'matrix:order_confirmation'
form_class = BillingAddressForm
@method_decorator(login_required)
def dispatch(self, *args, **kwargs):
return super().dispatch(*args, **kwargs)
def get_context_data(self, **kwargs):
context = super(OrderPaymentView, self).get_context_data(**kwargs)
if 'billing_address_data' in self.request.session:
billing_address_form = BillingAddressForm(
initial=self.request.session['billing_address_data']
)
else:
old_active = get_billing_address_for_user(self.request.user)
billing_address_form = BillingAddressForm(
instance=old_active
) if old_active else BillingAddressForm(
initial={'active': True, 'owner': self.request.user.id}
)
customer_id = uncloud_stripe.get_customer_id_for(self.request.user)
cards = uncloud_stripe.get_customer_cards(customer_id)
context.update({
'matrix_vm_pricing': PricingPlan.get_by_name(self.request.session.get('pricing', {'name': 'unknown'})['name']),
'billing_address_form': billing_address_form,
'cards': cards,
'stripe_key': settings.STRIPE_PUBLIC_KEY
})
return context
@cache_control(no_cache=True, must_revalidate=True, no_store=True)
def get(self, request, *args, **kwargs):
for k in ['vat_validation_status', 'token', 'id_payment_method']:
if request.session.get(k):
request.session.pop(k)
if 'order' not in request.session:
return HttpResponseRedirect(reverse('matrix:index'))
return self.render_to_response(self.get_context_data())
def form_valid(self, address_form):
id_payment_method = self.request.POST.get('id_payment_method', None)
self.request.session["id_payment_method"] = id_payment_method
this_user = {
'email': self.request.user.email,
'username': self.request.user.username
}
customer_id = uncloud_stripe.get_customer_id_for(self.request.user)
uncloud_stripe.attach_payment_method(id_payment_method, customer_id)
address = get_billing_address_for_user(self.request.user)
if address:
form = BillingAddressForm(self.request.POST, instance=address)
else:
form = BillingAddressForm(self.request.POST)
if form.is_valid:
billing_address_ins = form.save()
self.request.session["billing_address_id"] = billing_address_ins.id
self.request.session['billing_address_data'] = address_form.cleaned_data
self.request.session['billing_address_data']['owner'] = self.request.user.id
self.request.session['user'] = this_user
self.request.session['customer'] = customer_id
vat_number = address_form.cleaned_data.get('vat_number').strip()
if vat_number:
validate_result = validate_vat_number(
stripe_customer_id=customer_id,
billing_address_id=billing_address_ins.id
)
if 'error' in validate_result and validate_result['error']:
messages.add_message(
self.request, messages.ERROR, validate_result["error"],
extra_tags='vat_error'
)
return HttpResponseRedirect(
reverse('matrix:payment') + '#vat_error'
)
self.request.session["vat_validation_status"] = validate_result["status"]
return HttpResponseRedirect(reverse('matrix:order_details'))
class OrderDetailsView(DetailView):
template_name = "matrixhosting/order_detail.html"
context_object_name = "order"
model = Order
@method_decorator(login_required)
def dispatch(self, *args, **kwargs):
return super().dispatch(*args, **kwargs)
@cache_control(no_cache=True, must_revalidate=True, no_store=True)
def get(self, request, *args, **kwargs):
context = {}
if ('order' not in request.session or 'user' not in request.session):
return HttpResponseRedirect(reverse('matrix:index'))
if 'id_payment_method' in self.request.session:
card = uncloud_stripe.get_card_from_payment(self.request.user, self.request.session['id_payment_method'])
if not card:
return HttpResponseRedirect(reverse('matrix:payment'))
context['card'] = card
elif 'id_payment_method' not in self.request.session or 'vat_validation_status' not in self.request.session:
return HttpResponseRedirect(reverse('matrix:payment'))
specs = request.session.get('order')
pricing = request.session.get('pricing')
billing_address = BillingAddress.objects.get(id=request.session.get('billing_address_id'))
vat_rate = VATRate.get_vat_rate(billing_address)
vat_validation_status = "verified" if billing_address.vat_number_validated_on and billing_address.vat_number_verified else False
subtotal, subtotal_after_discount, price_after_discount_with_vat, vat, vat_percent, discount = get_order_total_with_vat(
specs['cores'], specs['memory'], specs['storage'], request.session['pricing']['name'],
vat_rate=vat_rate * 100, vat_validation_status = vat_validation_status
)
pricing = {
"subtotal": subtotal, "discount": discount, "vat": vat, "vat_percent": vat_percent,
"vat_country": billing_address.country.lower(),
"subtotal_after_discount": subtotal_after_discount,
"price_after_discount_with_vat": price_after_discount_with_vat
}
pricing["price_with_vat"] = round(subtotal * (1 + pricing["vat_percent"] * 0.01), 2)
discount["amount_with_vat"] = round(pricing["price_with_vat"] - pricing["price_after_discount_with_vat"], 2)
pricing["total_price"] = pricing["price_after_discount_with_vat"]
self.request.session['total_price'] = pricing["price_after_discount_with_vat"]
payment_intent_response = uncloud_stripe.get_payment_intent(request.user, pricing["price_after_discount_with_vat"])
context.update({
'payment_intent_secret': payment_intent_response.client_secret,
'order': specs,
'pricing': pricing,
'stripe_key': settings.STRIPE_PUBLIC_KEY,
})
return render(request, self.template_name, context)
def post(self, request, *args, **kwargs):
customer = StripeCustomer.objects.get(owner=self.request.user)
billing_address = BillingAddress.objects.get(id=request.session.get('billing_address_id'))
if 'id_payment_method' in request.session:
card = uncloud_stripe.get_card_from_payment(self.request.user, self.request.session['id_payment_method'])
if not card:
return show_error("There was a payment related error.", self.request)
else:
return show_error("There was a payment related error.", self.request)
order = finalize_order(request, customer,
billing_address,
self.request.session['total_price'],
PricingPlan.get_by_name(self.request.session['pricing']['name']),
request.session.get('order'))
if order:
bill = Bill.create_next_bill_for_user_address(billing_address)
payment= Payment.objects.create(owner=request.user, amount=self.request.session['total_price'], source='stripe')
if payment:
#Close the bill as the payment has been added
bill.close()
response = {
'status': True,
'redirect': (reverse('matrix:dashboard')),
'msg_title': str(_('Thank you for the order.')),
'msg_body': str(
_('Your VM will be up and running in a few moments.'
' We will send you a confirmation email as soon as'
' it is ready.'))
}
return JsonResponse(response)
def finalize_order(request, customer, billing_address,
one_time_price, pricing_plan,
specs):
product = Product.objects.first()
recurring_period_product = ProductToRecurringPeriod.objects.filter(product=product, is_default=True).first()
order = Order.objects.create(
owner=request.user,
customer=customer,
billing_address=billing_address,
one_time_price=one_time_price,
pricing_plan=pricing_plan,
recurring_period= recurring_period_product.recurring_period,
product = product,
config=json.dumps(specs)
)
return order
class Dashboard(ListView):
template_name = "matrixhosting/dashboard.html"
model = Order
@method_decorator(login_required)
def dispatch(self, *args, **kwargs):
return super().dispatch(*args, **kwargs)
def get_queryset(self):
return Order.objects.filter(owner=self.request.user)
def post(self, request, *args, **kwargs):
order = Order.objects.get(id=request.POST.get('order_id', 0))
order.cancel()
return JsonResponse({'message': 'Successfully Cancelled'})
def get_error_response_dict(request):
response = {
'status': False,
'redirect': "{url}#{section}".format(
url=(reverse('matrix:payment')),
section='payment_error'
),
'msg_title': str(_('Error.')),
'msg_body': str(
_('There was a payment related error.'
' On close of this popup, you will be redirected back to'
' the payment page.'))
}
return response
def show_error(msg, request):
messages.add_message(request, messages.ERROR, msg,
extra_tags='failed_payment')
return JsonResponse(get_error_response_dict(request))
class MachineViewSet(viewsets.ReadOnlyModelViewSet):
serializer_class = VMInstanceSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
return VMInstance.objects.filter(owner=self.request.user)

View file

@ -0,0 +1,15 @@
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ name }}-matrix
spec:
selector:
matchLabels:
app: {{ name }}-matrix
replicas: 1
template:
metadata:
labels:
app: {{ name }}-matrix
use-as-service: {{ name }}

View file

@ -1,16 +1,15 @@
# Django basics
django
Django==3.2.4
djangorestframework
django-auth-ldap
django-bootstrap-v5
fontawesome-free
psycopg2
ldap3
django-allauth
xmltodict
parsedatetime
# Follow are for creating graph models
pyparsing
pydot
@ -22,12 +21,14 @@ django-hardcopy
# schema support
pyyaml
uritemplate
tldextract
# Payment & VAT
vat-validator
stripe
# Tasks
celery
#Jobs
django-q
redis
jinja2
python-gitlab

View file

@ -1,6 +1,5 @@
from django.utils.translation import gettext_lazy as _
import decimal
from .celery import app as celery_app
# Define DecimalField properties, used to represent amounts of money.
AMOUNT_MAX_DIGITS=10
@ -251,4 +250,4 @@ COUNTRIES = (
)
__all__ = ('celery_app',)
__all__ = ()

View file

@ -2,5 +2,5 @@ from django.contrib import admin
from .models import *
for m in [ UncloudProvider, UncloudNetwork, UncloudTask ]:
for m in [ UncloudProvider, UncloudNetwork ]:
admin.site.register(m)

View file

@ -1,17 +0,0 @@
import os
from celery import Celery
# set the default Django settings module for the 'celery' program.
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'uncloud.settings')
app = Celery('uncloud')
# Using a string here means the worker doesn't have to serialize
# the configuration object to child processes.
# - namespace='CELERY' means all celery-related configuration keys
# should have a `CELERY_` prefix.
app.config_from_object('django.conf:settings', namespace='CELERY')
# Load task modules from all registered Django app configs.
app.autodiscover_tasks()

8
uncloud/forms.py Normal file
View file

@ -0,0 +1,8 @@
from django import forms
from django.contrib.auth.models import User
class UserDeleteForm(forms.ModelForm):
class Meta:
model = User
fields = []

View file

@ -0,0 +1,16 @@
# Generated by Django 3.2.4 on 2021-07-07 15:11
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('uncloud', '0004_auto_20210101_1308'),
]
operations = [
migrations.DeleteModel(
name='UncloudTask',
),
]

View file

@ -57,10 +57,10 @@ class CountryField(models.CharField):
class UncloudAddress(models.Model):
full_name = models.CharField(max_length=256)
full_name = models.CharField(max_length=256, null=False)
organization = models.CharField(max_length=256, blank=True, null=True)
street = models.CharField(max_length=256)
city = models.CharField(max_length=256)
street = models.CharField(max_length=256, null=False)
city = models.CharField(max_length=256, null=False)
postal_code = models.CharField(max_length=64)
country = CountryField(blank=False, null=False)
@ -207,18 +207,3 @@ class UncloudProvider(UncloudAddress):
def __str__(self):
return f"{self.full_name} {self.country}"
class UncloudTask(models.Model):
"""
Class to store dispatched tasks to be handled
"""
task_id = models.UUIDField(primary_key=True)
# class UncloudRequestLog(models.Model):
# """
# Class to store requests and logs
# """
# log = models.CharField(max_length=256)

View file

@ -13,6 +13,7 @@ https://docs.djangoproject.com/en/3.0/ref/settings/
import os
import re
import ldap
import sys
from django.core.management.utils import get_random_secret_key
from django_auth_ldap.config import LDAPSearch, LDAPSearchUnion
@ -23,6 +24,8 @@ LOGGING = {}
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.modules['fontawesome_free'] = __import__('fontawesome-free')
# https://docs.djangoproject.com/en/3.0/ref/settings/#databases
DATABASES = {
'default': {
@ -38,7 +41,7 @@ DATABASES = {
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
SITE_ID = 1
# Application definition
@ -48,10 +51,16 @@ INSTALLED_APPS = [
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.sites',
'allauth',
'allauth.account',
'allauth.socialaccount',
'django.contrib.staticfiles',
'django_extensions',
'rest_framework',
'bootstrap5',
'django_q',
'fontawesome_free',
'uncloud',
'uncloud_pay',
'uncloud_auth',
@ -59,7 +68,8 @@ INSTALLED_APPS = [
'uncloud_storage',
'uncloud_vm',
'uncloud_service',
'opennebula'
'opennebula',
'matrixhosting',
]
MIDDLEWARE = [
@ -110,7 +120,13 @@ AUTH_PASSWORD_VALIDATORS = [
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
]
###############################################################################
# Authall Settings
ACCOUNT_AUTHENTICATION_METHOD = "username"
ACCOUNT_EMAIL_CONFIRMATION_EXPIRE_DAYS = 1
ACCOUNT_EMAIL_REQUIRED = False
ACCOUNT_EMAIL_VERIFICATION = "optional"
ACCOUNT_UNIQUE_EMAIL = False
################################################################################
# AUTH/LDAP
@ -131,7 +147,8 @@ AUTH_LDAP_USER_ATTR_MAP = {
# AUTH/Django
AUTHENTICATION_BACKENDS = [
"django_auth_ldap.backend.LDAPBackend",
"django.contrib.auth.backends.ModelBackend"
"django.contrib.auth.backends.ModelBackend",
'allauth.account.auth_backends.AuthenticationBackend',
]
AUTH_USER_MODEL = 'uncloud_auth.User'
@ -170,6 +187,13 @@ STATICFILES_FINDERS = [
'django.contrib.staticfiles.finders.AppDirectoriesFinder',
]
#VM Deployment TEMPLATE
GITLAB_SERVER = 'https://code.ungleich.ch'
GITLAB_OAUTH_TOKEN = ''
GITLAB_PROJECT_ID = 388
GITLAB_AUTHOR_EMAIL = ''
GITLAB_AUTHOR_NAME = ''
GITLAB_YAML_DIR = ''
# XML-RPC interface of opennebula
OPENNEBULA_URL = 'https://opennebula.example.com:2634/RPC2'
@ -180,7 +204,7 @@ OPENNEBULA_USER_PASS = 'user:password'
# Stripe (Credit Card payments)
STRIPE_KEY=""
STRIPE_PUBLIC_KEY=""
BILL_PAYMENT_DELAY = 0
# The django secret key
SECRET_KEY=get_random_secret_key()
@ -206,43 +230,34 @@ AUTH_LDAP_USER_SEARCH = LDAPSearch("dc=example,dc=com",
# where to create customers
LDAP_CUSTOMER_DN="ou=customer,dc=example,dc=com"
# def route_task(name, args, kwargs, options, task=None, **kw):
# print(f"{name} - {args} - {kwargs}")
# # if name == 'myapp.tasks.compress_video':
# return {'queue': 'vpn1' }
# # 'exchange_type': 'topic',
# # 'routing_key': 'video.compress'}
EMAIL_USE_TLS = True
EMAIL_HOST = ''
EMAIL_PORT = 465
EMAIL_HOST_USER = DEFAULT_FROM_EMAIL = ''
EMAIL_HOST_PASSWORD = ''
DEFAULT_FROM_EMAIL = ''
RENEWAL_FROM_EMAIL = 'test@example.com'
# Should be removed in production
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
# CELERY_TASK_ROUTES = (route_task,)
# CELERY_TASK_ROUTES = {
# '*': {
# 'queue': 'vpn1'
# }
# }
CELERY_BROKER_URL = 'redis://:uncloud.example.com:6379/0'
CELERY_RESULT_BACKEND = 'redis://:uncloud.example.com:6379/0'
CELERY_TASK_ROUTES = {
re.compile(r'.*.tasks.cdist.*'): { 'queue': 'cdist' } # cdist tasks go into cdist queue
##############
# Jobs
Q_CLUSTER = {
'name': 'matrixhosting',
'workers': 1,
'recycle': 500,
'timeout': 60,
'compress': True,
'cpu_affinity': 1,
'save_limit': 250,
'queue_limit': 500,
'label': 'Django Q',
'redis': {
'host': '127.0.0.1',
'port': 6379,
'db': 0, }
}
CELERY_BEAT_SCHEDULE = {
'cleanup_tasks': {
'task': 'uncloud.tasks.cleanup_tasks',
'schedule': 10
},
'check_balance': {
'task': 'uncloud_pay.tasks.check_balance',
'schedule': 15
}
}
# CELERY_TASK_CREATE_MISSING_QUEUES = False
# Overwrite settings with local settings, if existing
try:
from uncloud.local_settings import *

View file

@ -1,22 +0,0 @@
from celery import shared_task
from celery.result import AsyncResult
from .models import UncloudTask
@shared_task(bind=True)
def cleanup_tasks(self):
print(f"Cleanup time from {self}: {self.request.id}")
for task in UncloudTask.objects.all():
print(f"Pruning {task}...")
if str(task.task_id) == str(self.request.id):
print("Skipping myself")
continue
res = AsyncResult(id=str(task.task_id))
print(f"Task {task}: {res.state}")
if res.ready():
print(res.get())
task.delete()
res.forget()

View file

@ -4,6 +4,7 @@
<nav class="navbar sticky-top navbar-expand-lg navbar-light bg-light">
<div class="container">
<a class="navbar-brand" href="{% url 'uncloudindex' %}">uncloud</a>
<a class="navbar-brand" href="{% url 'matrix:index' %}">Matrix Hosting</a>
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
@ -14,11 +15,11 @@
<span class="navbar-text">Logged in as {{ user }}. Your balance:
{{ balance }} CHF. </span>
<li class="nav-item">
<a class="nav-link" href="{% url 'logout' %}">Logout</a>
<a class="nav-link" href="{% url 'account_logout' %}">Logout</a>
</li>
{% else %}
<li class="nav-item">
<a class="nav-link" href="{% url 'login' %}">Login</a>
<a class="nav-link" href="{% url 'account_login' %}">Login</a>
</li>
{% endif %}
</ul>

View file

@ -38,7 +38,7 @@
<li>First you need
to <a href="https://account.ungleich.ch">register an
account</a>. If you already have one, you can
<a href="{% url 'login' %}">login</a>.
<a href="{% url 'account_login' %}">login</a>.
<li>If you have forgotten your password or other issues with
logging in, you can contact the ungleich support
via <strong>support at ungleich.ch</strong>.
@ -107,8 +107,11 @@
<ul>
<li><a href="{% url 'billingaddress-list' %}">Create or list
your billing addresses</a>
<li><a href="{% url 'orders-list' %}">List your Orders</a>
<li><a href="{% url 'bills-list' %}">List your Bills</a>
<li><a href="{% url 'payment-list' %}">Make a payment or list your payments</a>
<li><a href="{% url 'payment-balance-list' %}">Show your balance</a>
<li><a href="{% url 'machines-list' %}">Show your VM Instances</a>
</ul>
</div>
</div>
@ -138,11 +141,30 @@
<div class="col-8">
<ul>
<li>Payments are only possible in CHF.
<li>Bills are not yet visible (payments are, though)
</ul>
</div>
</div>
{% if user.is_authenticated %}
<div id="account-settings" class="row">
<div class="col"><h3>Account Settings</h3></div>
<div class="col-8">
<ul>
<form method="POST">
{% csrf_token %}
<fieldset class="form-group">
<legend class="border-bottom mb-4">Delete User Account</legend>
<p>Are you sure you want to delete your account? This will permanently delete your
profile and any orders you have generated.</p>
{{ delete_form }}
</fieldset>
<div class="form-group">
<button class="btn btn-danger btn-lg" type="submit" name="action">Delete Account</button>
</div>
</form>
</ul>
</div>
</div>
{% endif %}
</div>
</div>
{% endblock %}

View file

@ -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"),
]

View file

@ -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')

View file

@ -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

View file

@ -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.

View file

@ -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()

View file

@ -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()

View file

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

View file

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

View file

@ -1,11 +1,14 @@
from django.core.management.base import BaseCommand
from uncloud_pay.models import VATRate
import logging
import urllib
import csv
import sys
import io
logger = logging.getLogger(__name__)
class Command(BaseCommand):
help = '''Imports VAT Rates. Assume vat rates of format https://github.com/kdeldycke/vat-rates/blob/master/vat_rates.csv'''
vat_url = "https://raw.githubusercontent.com/ungleich/vat-rates/main/vat_rates.csv"
@ -23,8 +26,19 @@ class Command(BaseCommand):
reader = csv.DictReader(csv_file)
for row in reader:
# print(row)
obj, created = VATRate.objects.get_or_create(
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"],
@ -33,3 +47,4 @@ class Command(BaseCommand):
rate_type=row["rate_type"],
description=row["description"]
)
logger.info('All VAT Rates have been added!')

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,5 +1,6 @@
import logging
import datetime
import json
from math import ceil
from calendar import monthrange
@ -9,18 +10,22 @@ from django.conf import settings
from django.contrib.auth import get_user_model
from django.core.validators import MinValueValidator
from django.db import models
from django.db.models import Q
from django.utils.translation import gettext_lazy as _
from django.utils import timezone
from django_q.tasks import schedule
from django_q.models import Schedule
# Verify whether or not to use them here
from django.core.exceptions import ObjectDoesNotExist, ValidationError
import uncloud_pay
from uncloud import AMOUNT_DECIMALS, AMOUNT_MAX_DIGITS
from uncloud.models import UncloudAddress
from uncloud.models import UncloudAddress, UncloudProvider
from uncloud.selectors import filter_for_when
from .services import *
# Used to generate bill due dates.
BILL_PAYMENT_DELAY=datetime.timedelta(days=10)
BILL_PAYMENT_DELAY=datetime.timedelta(days=settings.BILL_PAYMENT_DELAY)
# Initialize logger.
logger = logging.getLogger(__name__)
@ -96,84 +101,18 @@ class Payment(models.Model):
def __str__(self):
return f"{self.amount}{self.currency} from {self.owner} via {self.source} on {self.timestamp}"
###
# Payments and Payment Methods.
class PaymentMethod(models.Model):
"""
Not sure if this is still in use
"""
owner = models.ForeignKey(get_user_model(),
on_delete=models.CASCADE,
editable=False)
source = models.CharField(max_length=256,
choices = (
('stripe', 'Stripe'),
('unknown', 'Unknown'),
),
default='stripe')
description = models.TextField()
primary = models.BooleanField(default=False, editable=False)
# Only used for "Stripe" source
stripe_payment_method_id = models.CharField(max_length=32, blank=True, null=True)
stripe_setup_intent_id = models.CharField(max_length=32, blank=True, null=True)
@property
def active(self):
if self.source == 'stripe' and self.stripe_payment_method_id != None:
return True
else:
return False
def charge(self, amount):
if not self.active:
raise Exception('This payment method is inactive.')
if amount < 0: # Make sure we don't charge negative amount by errors...
raise Exception('Cannot charge negative amount.')
def save(self, *args, **kwargs):
# Try to charge the user via the active card before saving otherwise throw payment Error
if self.source == 'stripe':
stripe_customer = StripeCustomer.objects.get(owner=self.owner).stripe_id
stripe_payment = uncloud_pay.stripe.charge_customer(
amount, stripe_customer, self.stripe_payment_method_id)
if 'paid' in stripe_payment and stripe_payment['paid'] == False:
raise Exception(stripe_payment['error'])
else:
payment = Payment.objects.create(
owner=self.owner, source=self.source, amount=amount)
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
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
# 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:
@ -273,6 +214,10 @@ class BillingAddress(UncloudAddress):
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
@ -298,9 +243,43 @@ class VATRate(models.Model):
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)
@ -751,6 +739,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):
"""
How many times this order was billed so far.
@ -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()
@ -834,22 +831,24 @@ class Order(models.Model):
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:
if self.recurring_price != 0:
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=self.starting_date,
ending_date=bill.ending_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:
else:
br = self.create_new_bill_record_for_recurring_order(bill)
else:
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
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
def calculate_recurring_price(self):
try:
value = self.config['features'][feature]
except (KeyError, TypeError):
value = self.product.config['features'][feature]['min']
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'])
# 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')
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
@ -1167,9 +1117,11 @@ class BillRecord(models.Model):
if not self.is_recurring_record:
return 1
record_delta = self.ending_date - self.starting_date
return record_delta.total_seconds()/self.order.recurring_period.duration_seconds
record_delta = self.ending_date.date() - self.starting_date.date()
if self.order.recurring_period and self.order.recurring_period.duration_seconds > 0:
return int(record_delta.total_seconds() / self.order.recurring_period.duration_seconds)
else:
return 1
@property
def sum(self):

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -5,52 +5,28 @@ from django.utils import timezone
from .models import *
from uncloud_service.models import GenericServiceProduct
from uncloud.models import UncloudProvider
from uncloud.models import UncloudProvider, UncloudNetwork
import json
chocolate_product_config = {
'features': {
'gramm':
{ 'min': 100,
'max': 5000,
'one_time_price_per_unit': 0.2,
'recurring_price_per_unit': 0
},
},
}
chocolate_order_config = {
'features': {
'gramm': 500,
}
}
chocolate_one_time_price = chocolate_order_config['features']['gramm'] * chocolate_product_config['features']['gramm']['one_time_price_per_unit']
vm_product_config = {
'features': {
'cores':
{ 'min': 1,
'max': 48,
'one_time_price_per_unit': 0,
'recurring_price_per_unit': 4
'max': 48
},
'ram_gb':
{ 'min': 1,
'max': 256,
'one_time_price_per_unit': 0,
'recurring_price_per_unit': 4
'max': 256
},
},
}
vm_order_config = {
'features': {
'cores': 2,
'ram_gb': 2
}
}
vm_order_config = json.dumps({
'cores': 1,
'memory': 2,
'storage': 100
})
vm_order_downgrade_config = {
'features': {
@ -92,7 +68,6 @@ class ProductTestCase(TestCase):
"""
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)
@ -479,6 +521,7 @@ class VATRatesTestCase(TestCase):
postal_code="unknown",
active=True)
UncloudNetwork.populate_db_defaults()
UncloudProvider.populate_db_defaults()

155
uncloud_pay/utils.py Normal file
View file

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

View file

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