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/ build/
venv/ venv/
dist/ dist/
.history/
*.iso *.iso
*.sqlite3 *.sqlite3

View file

@ -9,13 +9,15 @@ Cloud management platform, the ungleich way.
## Useful commands ## Useful commands
* `./manage.py import-vat-rates path/to/csv` * `./manage.py import-vat-rates path/to/csv`
* `./manage.py make-admin username` * `./manage.py createsuperuser`
## Development setup ## Development setup
Install system dependencies: Install system dependencies:
* On Fedora, you will need the following packages: `python3-virtualenv python3-devel openldap-devel gcc chromium` * 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`. 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/ Starting development server at http://127.0.0.1:8000/
Quit the server with CONTROL-C. 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 ### 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 basics
django Django==3.2.4
djangorestframework djangorestframework
django-auth-ldap django-auth-ldap
django-bootstrap-v5 django-bootstrap-v5
fontawesome-free
psycopg2 psycopg2
ldap3 ldap3
django-allauth
xmltodict xmltodict
parsedatetime parsedatetime
# Follow are for creating graph models # Follow are for creating graph models
pyparsing pyparsing
pydot pydot
@ -22,12 +21,14 @@ django-hardcopy
# schema support # schema support
pyyaml pyyaml
uritemplate uritemplate
tldextract
# Payment & VAT # Payment & VAT
vat-validator vat-validator
stripe stripe
#Jobs
# Tasks django-q
celery
redis redis
jinja2
python-gitlab

View file

@ -1,6 +1,5 @@
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
import decimal import decimal
from .celery import app as celery_app
# Define DecimalField properties, used to represent amounts of money. # Define DecimalField properties, used to represent amounts of money.
AMOUNT_MAX_DIGITS=10 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 * from .models import *
for m in [ UncloudProvider, UncloudNetwork, UncloudTask ]: for m in [ UncloudProvider, UncloudNetwork ]:
admin.site.register(m) 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): 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) organization = models.CharField(max_length=256, blank=True, null=True)
street = models.CharField(max_length=256) street = models.CharField(max_length=256, null=False)
city = models.CharField(max_length=256) city = models.CharField(max_length=256, null=False)
postal_code = models.CharField(max_length=64) postal_code = models.CharField(max_length=64)
country = CountryField(blank=False, null=False) country = CountryField(blank=False, null=False)
@ -207,18 +207,3 @@ class UncloudProvider(UncloudAddress):
def __str__(self): def __str__(self):
return f"{self.full_name} {self.country}" 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 os
import re import re
import ldap import ldap
import sys
from django.core.management.utils import get_random_secret_key from django.core.management.utils import get_random_secret_key
from django_auth_ldap.config import LDAPSearch, LDAPSearchUnion 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, ...) # Build paths inside the project like this: os.path.join(BASE_DIR, ...)
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 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 # https://docs.djangoproject.com/en/3.0/ref/settings/#databases
DATABASES = { DATABASES = {
'default': { 'default': {
@ -38,7 +41,7 @@ DATABASES = {
# SECURITY WARNING: don't run with debug turned on in production! # SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True DEBUG = True
SITE_ID = 1
# Application definition # Application definition
@ -48,10 +51,16 @@ INSTALLED_APPS = [
'django.contrib.contenttypes', 'django.contrib.contenttypes',
'django.contrib.sessions', 'django.contrib.sessions',
'django.contrib.messages', 'django.contrib.messages',
'django.contrib.sites',
'allauth',
'allauth.account',
'allauth.socialaccount',
'django.contrib.staticfiles', 'django.contrib.staticfiles',
'django_extensions', 'django_extensions',
'rest_framework', 'rest_framework',
'bootstrap5', 'bootstrap5',
'django_q',
'fontawesome_free',
'uncloud', 'uncloud',
'uncloud_pay', 'uncloud_pay',
'uncloud_auth', 'uncloud_auth',
@ -59,7 +68,8 @@ INSTALLED_APPS = [
'uncloud_storage', 'uncloud_storage',
'uncloud_vm', 'uncloud_vm',
'uncloud_service', 'uncloud_service',
'opennebula' 'opennebula',
'matrixhosting',
] ]
MIDDLEWARE = [ MIDDLEWARE = [
@ -110,7 +120,13 @@ AUTH_PASSWORD_VALIDATORS = [
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', '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 # AUTH/LDAP
@ -131,7 +147,8 @@ AUTH_LDAP_USER_ATTR_MAP = {
# AUTH/Django # AUTH/Django
AUTHENTICATION_BACKENDS = [ AUTHENTICATION_BACKENDS = [
"django_auth_ldap.backend.LDAPBackend", "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' AUTH_USER_MODEL = 'uncloud_auth.User'
@ -170,6 +187,13 @@ STATICFILES_FINDERS = [
'django.contrib.staticfiles.finders.AppDirectoriesFinder', '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 # XML-RPC interface of opennebula
OPENNEBULA_URL = 'https://opennebula.example.com:2634/RPC2' OPENNEBULA_URL = 'https://opennebula.example.com:2634/RPC2'
@ -180,7 +204,7 @@ OPENNEBULA_USER_PASS = 'user:password'
# Stripe (Credit Card payments) # Stripe (Credit Card payments)
STRIPE_KEY="" STRIPE_KEY=""
STRIPE_PUBLIC_KEY="" STRIPE_PUBLIC_KEY=""
BILL_PAYMENT_DELAY = 0
# The django secret key # The django secret key
SECRET_KEY=get_random_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 # where to create customers
LDAP_CUSTOMER_DN="ou=customer,dc=example,dc=com" LDAP_CUSTOMER_DN="ou=customer,dc=example,dc=com"
# def route_task(name, args, kwargs, options, task=None, **kw): EMAIL_USE_TLS = True
# print(f"{name} - {args} - {kwargs}") EMAIL_HOST = ''
# # if name == 'myapp.tasks.compress_video': EMAIL_PORT = 465
# return {'queue': 'vpn1' } EMAIL_HOST_USER = DEFAULT_FROM_EMAIL = ''
# # 'exchange_type': 'topic', EMAIL_HOST_PASSWORD = ''
# # 'routing_key': 'video.compress'} 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,) # Jobs
Q_CLUSTER = {
# CELERY_TASK_ROUTES = { 'name': 'matrixhosting',
# '*': { 'workers': 1,
# 'queue': 'vpn1' 'recycle': 500,
# } 'timeout': 60,
# } 'compress': True,
'cpu_affinity': 1,
'save_limit': 250,
CELERY_BROKER_URL = 'redis://:uncloud.example.com:6379/0' 'queue_limit': 500,
CELERY_RESULT_BACKEND = 'redis://:uncloud.example.com:6379/0' 'label': 'Django Q',
'redis': {
CELERY_TASK_ROUTES = { 'host': '127.0.0.1',
re.compile(r'.*.tasks.cdist.*'): { 'queue': 'cdist' } # cdist tasks go into cdist queue '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 # Overwrite settings with local settings, if existing
try: try:
from uncloud.local_settings import * 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"> <nav class="navbar sticky-top navbar-expand-lg navbar-light bg-light">
<div class="container"> <div class="container">
<a class="navbar-brand" href="{% url 'uncloudindex' %}">uncloud</a> <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"> <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> <span class="navbar-toggler-icon"></span>
</button> </button>
@ -14,11 +15,11 @@
<span class="navbar-text">Logged in as {{ user }}. Your balance: <span class="navbar-text">Logged in as {{ user }}. Your balance:
{{ balance }} CHF. </span> {{ balance }} CHF. </span>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="{% url 'logout' %}">Logout</a> <a class="nav-link" href="{% url 'account_logout' %}">Logout</a>
</li> </li>
{% else %} {% else %}
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="{% url 'login' %}">Login</a> <a class="nav-link" href="{% url 'account_login' %}">Login</a>
</li> </li>
{% endif %} {% endif %}
</ul> </ul>

View file

@ -38,7 +38,7 @@
<li>First you need <li>First you need
to <a href="https://account.ungleich.ch">register an to <a href="https://account.ungleich.ch">register an
account</a>. If you already have one, you can 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 <li>If you have forgotten your password or other issues with
logging in, you can contact the ungleich support logging in, you can contact the ungleich support
via <strong>support at ungleich.ch</strong>. via <strong>support at ungleich.ch</strong>.
@ -107,8 +107,11 @@
<ul> <ul>
<li><a href="{% url 'billingaddress-list' %}">Create or list <li><a href="{% url 'billingaddress-list' %}">Create or list
your billing addresses</a> 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-list' %}">Make a payment or list your payments</a>
<li><a href="{% url 'payment-balance-list' %}">Show your balance</a> <li><a href="{% url 'payment-balance-list' %}">Show your balance</a>
<li><a href="{% url 'machines-list' %}">Show your VM Instances</a>
</ul> </ul>
</div> </div>
</div> </div>
@ -138,11 +141,30 @@
<div class="col-8"> <div class="col-8">
<ul> <ul>
<li>Payments are only possible in CHF. <li>Payments are only possible in CHF.
<li>Bills are not yet visible (payments are, though)
</ul> </ul>
</div> </div>
</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>
</div> </div>
{% endblock %} {% endblock %}

View file

@ -19,6 +19,7 @@ from uncloud_net import views as netviews
from uncloud_pay import views as payviews from uncloud_pay import views as payviews
from uncloud_vm import views as vmviews from uncloud_vm import views as vmviews
from uncloud_service import views as serviceviews from uncloud_service import views as serviceviews
from matrixhosting import views as matrixviews
router = routers.DefaultRouter() 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/payment', payviews.PaymentViewSet, basename='payment')
router.register(r'v2/payment/balance', payviews.BalanceViewSet, basename='payment-balance') 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/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 # Generic helper views that are usually not needed
router.register(r'v2/generic/vat-rate', payviews.VATRateViewSet, basename='vatrate') router.register(r'v2/generic/vat-rate', payviews.VATRateViewSet, basename='vatrate')
@ -54,9 +58,8 @@ urlpatterns = [
path('admin/', admin.site.urls), path('admin/', admin.site.urls),
path('login/', authviews.LoginView.as_view(), name="login"), path('accounts/', include('allauth.urls')),
path('logout/', authviews.LogoutView.as_view(), name="logout"),
path('cc/reg/', payviews.RegisterCard.as_view(), name="cc_register"), path('cc/reg/', payviews.RegisterCard.as_view(), name="cc_register"),
path('matrix/', include('matrixhosting.urls', namespace='matrix')),
path('', uncloudviews.UncloudIndex.as_view(), name="uncloudindex"), path('', uncloudviews.UncloudIndex.as_view(), name="uncloudindex"),
] ]

View file

@ -1,13 +1,23 @@
from django.views.generic.base import TemplateView 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 uncloud_pay.selectors import get_balance_for_user
from .forms import UserDeleteForm
class UncloudIndex(TemplateView): class UncloudIndex(TemplateView):
template_name = "uncloud/index.html" template_name = "uncloud/index.html"
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
print(context)
if self.request.user.is_authenticated: if self.request.user.is_authenticated:
context['balance'] = get_balance_for_user(self.request.user) context['balance'] = get_balance_for_user(self.request.user)
context['delete_form'] = UserDeleteForm(instance=self.request.user)
return context 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 .models import *
from .selectors import * from .selectors import *
from .tasks import * from .tasks import *
from django_q.tasks import async_task, result
@transaction.atomic @transaction.atomic
def create_wireguard_vpn(owner, public_key, network_mask): 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 server = pool.vpn_server_hostname
wg_name = pool.wg_name wg_name = pool.wg_name
configure_wireguard_server_on_host.apply_async((wg_name, config), async_task(configure_wireguard_server_on_host, (wg_name, config), queue=server)
queue=server)
return vpn return vpn

View file

@ -1,17 +1,14 @@
from celery import shared_task
from .models import * from .models import *
from uncloud.models import UncloudTask
import os import os
import subprocess import subprocess
import logging import logging
import uuid import uuid
from django_q.tasks import async_task, result
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@shared_task
def configure_wireguard_server_on_host(wg_name, config): def configure_wireguard_server_on_host(wg_name, config):
""" """
- Create wireguard config (DB query -> string) - 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)") log.info(f"Configuring VPN server {server} (async)")
task_id = uuid.UUID(cdist_configure_wireguard_server.apply_async((config, server)).id) async_task(cdist_configure_wireguard_server,config, server).id
UncloudTask.objects.create(task_id=task_id)
@shared_task
def cdist_configure_wireguard_server(config, server): def cdist_configure_wireguard_server(config, server):
""" """
Create config and configure server. Create config and configure server.

View file

@ -37,7 +37,7 @@ class VPNTests(TestCase):
self.vpn_wireguard_public_key = 'B2b78eWBIXPMM1x4DDjkCDZepS0qDgcLN3T3PjcgXkY=' 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, network_size=self.pool_network_size,
subnetwork_size=self.pool_subnetwork_size, subnetwork_size=self.pool_subnetwork_size,
vpn_hostname=self.pool_vpn_hostname, vpn_hostname=self.pool_vpn_hostname,
@ -47,55 +47,6 @@ class VPNTests(TestCase):
self.factory = APIRequestFactory() 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): def tearDown(self):
self.user.delete() self.user.delete()

View file

@ -61,10 +61,3 @@ class WireGuardVPNSizes(viewsets.ViewSet):
print(sizes) print(sizes)
return Response(WireGuardVPNSizesSerializer(sizes, many=True).data) 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.shortcuts import render
from django.conf.urls import url from django.conf.urls import url
from uncloud_pay.views import BillViewSet
from hardcopy import bytestring_to_pdf from hardcopy import bytestring_to_pdf
from django.core.files.temp import NamedTemporaryFile from django.core.files.temp import NamedTemporaryFile
from django.http import FileResponse from django.http import FileResponse
@ -90,14 +89,15 @@ admin.site.register(Bill, BillAdmin)
admin.site.register(Product, ProductAdmin) admin.site.register(Product, ProductAdmin)
for m in [ for m in [
BillRecord,
BillingAddress, BillingAddress,
Order, Order,
BillRecord,
Payment, Payment,
ProductToRecurringPeriod, ProductToRecurringPeriod,
RecurringPeriod, RecurringPeriod,
StripeCreditCard, StripeCreditCard,
StripeCustomer, StripeCustomer,
VATRate, PricingPlan,
VATRate
]: ]:
admin.site.register(m) admin.site.register(m)

View file

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

View file

@ -1,11 +1,14 @@
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from uncloud_pay.models import VATRate from uncloud_pay.models import VATRate
import logging
import urllib import urllib
import csv import csv
import sys import sys
import io import io
logger = logging.getLogger(__name__)
class Command(BaseCommand): class Command(BaseCommand):
help = '''Imports VAT Rates. Assume vat rates of format https://github.com/kdeldycke/vat-rates/blob/master/vat_rates.csv''' 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" 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) reader = csv.DictReader(csv_file)
for row in reader: for row in reader:
# print(row) if row["territory_codes"] and len(row["territory_codes"].splitlines()) > 1:
obj, created = VATRate.objects.get_or_create( 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"], starting_date=row["start_date"],
ending_date=row["stop_date"] if row["stop_date"] != "" else None, ending_date=row["stop_date"] if row["stop_date"] != "" else None,
territory_codes=row["territory_codes"], territory_codes=row["territory_codes"],
@ -33,3 +47,4 @@ class Command(BaseCommand):
rate_type=row["rate_type"], rate_type=row["rate_type"],
description=row["description"] 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 logging
import datetime import datetime
import json
from math import ceil from math import ceil
from calendar import monthrange from calendar import monthrange
@ -9,18 +10,22 @@ from django.conf import settings
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.core.validators import MinValueValidator from django.core.validators import MinValueValidator
from django.db import models from django.db import models
from django.db.models import Q
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.utils import timezone 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 # Verify whether or not to use them here
from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.core.exceptions import ObjectDoesNotExist, ValidationError
import uncloud_pay
from uncloud import AMOUNT_DECIMALS, AMOUNT_MAX_DIGITS 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 * from .services import *
# Used to generate bill due dates. # 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. # Initialize logger.
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -96,84 +101,18 @@ class Payment(models.Model):
def __str__(self): def __str__(self):
return f"{self.amount}{self.currency} from {self.owner} via {self.source} on {self.timestamp}" return f"{self.amount}{self.currency} from {self.owner} via {self.source} on {self.timestamp}"
### def save(self, *args, **kwargs):
# Payments and Payment Methods. # Try to charge the user via the active card before saving otherwise throw payment Error
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.')
if self.source == 'stripe': if self.source == 'stripe':
stripe_customer = StripeCustomer.objects.get(owner=self.owner).stripe_id try:
stripe_payment = uncloud_pay.stripe.charge_customer( result = uncloud_pay.stripe.charge_customer(self.owner, self.amount, self.currency,)
amount, stripe_customer, self.stripe_payment_method_id) if not result.status or result.status != 'succeeded':
if 'paid' in stripe_payment and stripe_payment['paid'] == False: raise Exception("The payment has been failed, please try to activate another card")
raise Exception(stripe_payment['error']) super().save(*args, **kwargs)
else: except Exception as e:
payment = Payment.objects.create( raise e
owner=self.owner, source=self.source, amount=amount)
return payment
else:
raise Exception('This payment method is unsupported/cannot be charged.')
def set_as_primary_for(self, user):
methods = PaymentMethod.objects.filter(owner=user, primary=True)
for method in methods:
print(method)
method.primary = False
method.save()
self.primary = True
self.save()
def get_primary_for(user):
methods = PaymentMethod.objects.filter(owner=user)
for method in methods:
# Do we want to do something with non-primary method?
if method.active and method.primary:
return method
return None
class Meta:
# TODO: limit to one primary method per user.
# unique_together is no good since it won't allow more than one
# non-primary method.
pass
# See https://docs.djangoproject.com/en/dev/ref/models/fields/#field-choices-enum-types # See https://docs.djangoproject.com/en/dev/ref/models/fields/#field-choices-enum-types
class RecurringPeriodDefaultChoices(models.IntegerChoices): class RecurringPeriodDefaultChoices(models.IntegerChoices):
@ -231,9 +170,11 @@ class RecurringPeriod(models.Model):
# Bills. # Bills.
class BillingAddress(UncloudAddress): 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 = models.CharField(max_length=100, default="", blank=True)
vat_number_verified = models.BooleanField(default=False) 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) active = models.BooleanField(default=False)
class Meta: class Meta:
@ -273,6 +214,10 @@ class BillingAddress(UncloudAddress):
self.full_name, self.street, self.postal_code, self.city, self.full_name, self.street, self.postal_code, self.city,
self.country) self.country)
@staticmethod
def get_address_for(user):
return BillingAddress.objects.get(owner=user)
### ###
# VAT # VAT
@ -298,9 +243,43 @@ class VATRate(models.Model):
logger.debug("Did not find VAT rate for %s, returning 0" % country_code) logger.debug("Did not find VAT rate for %s, returning 0" % country_code)
return 0 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): 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 # Products
@ -342,30 +321,20 @@ class Product(models.Model):
'features': { 'features': {
'cores': 'cores':
{ 'min': 1, { 'min': 1,
'max': 48, 'max': 48
'one_time_price_per_unit': 0,
'recurring_price_per_unit': 3
}, },
'ram_gb': 'ram_gb':
{ 'min': 1, { 'min': 1,
'max': 256, 'max': 256
'one_time_price_per_unit': 0,
'recurring_price_per_unit': 4
}, },
'ssd_gb': 'ssd_gb':
{ 'min': 10, { 'min': 10
'one_time_price_per_unit': 0,
'recurring_price_per_unit': 0.35
}, },
'hdd_gb': 'hdd_gb':
{ 'min': 0, { 'min': 0,
'one_time_price_per_unit': 0,
'recurring_price_per_unit': 15/1000
}, },
'additional_ipv4_address': 'additional_ipv4_address':
{ 'min': 0, { 'min': 0,
'one_time_price_per_unit': 0,
'recurring_price_per_unit': 8
}, },
} }
} }
@ -381,36 +350,23 @@ class Product(models.Model):
'base': 'base':
{ 'min': 1, { 'min': 1,
'max': 1, 'max': 1,
'one_time_price_per_unit': 0,
'recurring_price_per_unit': 1
}, },
'cores': 'cores':
{ 'min': 1, { 'min': 1,
'max': 48, 'max': 48,
'one_time_price_per_unit': 0,
'recurring_price_per_unit': 3
}, },
'ram_gb': 'ram_gb':
{ 'min': 1, { 'min': 1,
'max': 256, 'max': 256,
'one_time_price_per_unit': 0,
'recurring_price_per_unit': 4
}, },
'ssd_gb': 'ssd_gb':
{ 'min': 10, { 'min': 10
'one_time_price_per_unit': 0,
'recurring_price_per_unit': 0.35
}, },
'hdd_gb': 'hdd_gb':
{ 'min': 0, { 'min': 0
'one_time_price_per_unit': 0,
'recurring_price_per_unit': 15/1000
}, },
'additional_ipv4_address': 'additional_ipv4_address':
{ 'min': 0, { 'min': 0,},
'one_time_price_per_unit': 0,
'recurring_price_per_unit': 9
},
} }
} }
) )
@ -433,7 +389,7 @@ class Product(models.Model):
@property @property
def recurring_orders(self): 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 @property
def last_recurring_order(self): def last_recurring_order(self):
@ -441,56 +397,12 @@ class Product(models.Model):
@property @property
def one_time_orders(self): 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 @property
def last_one_time_order(self): def last_one_time_order(self):
return self.one_time_orders.last() 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 (?) # FIXME: this could/should be part of Order (?)
def create_or_update_recurring_order(self, when_to_start=None, recurring_period=None): def create_or_update_recurring_order(self, when_to_start=None, recurring_period=None):
if not self.recurring_price: if not self.recurring_price:
@ -618,10 +530,83 @@ class Product(models.Model):
super().save(*args, **kwargs) 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. # Orders.
class Order(models.Model): class Order(models.Model):
""" """
Order are assumed IMMUTABLE and used as SOURCE OF TRUST for generating 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, billing_address = models.ForeignKey(BillingAddress,
on_delete=models.CASCADE) on_delete=models.CASCADE)
customer = models.ForeignKey(StripeCustomer, on_delete=models.CASCADE, null=True)
description = models.TextField() description = models.TextField()
product = models.ForeignKey(Product, blank=False, null=False, on_delete=models.CASCADE) product = models.ForeignKey(Product, blank=False, null=False, on_delete=models.CASCADE)
@ -686,6 +673,7 @@ class Order(models.Model):
on_delete=models.CASCADE, on_delete=models.CASCADE,
blank=True, blank=True,
null=True) null=True)
pricing_plan = models.ForeignKey(PricingPlan, blank=False, null=True, on_delete=models.CASCADE)
should_be_billed = models.BooleanField(default=True) 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() ]) 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): def count_used(self, when=None):
""" """
How many times this order was billed so far. How many times this order was billed so far.
@ -790,7 +789,7 @@ class Order(models.Model):
@property @property
def is_recurring(self): def is_recurring(self):
return not self.recurring_period == RecurringPeriod.objects.get(name="ONE_TIME") return self.recurring_price > 0
@property @property
def is_one_time(self): def is_one_time(self):
@ -814,14 +813,12 @@ class Order(models.Model):
description=self.description, description=self.description,
product=self.product, product=self.product,
config=config, config=config,
pricing_plan=self.pricing_plan,
starting_date=starting_date, starting_date=starting_date,
currency=self.currency 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.replaces = self
new_order.save() new_order.save()
@ -834,22 +831,24 @@ class Order(models.Model):
def create_bill_record(self, bill): def create_bill_record(self, bill):
br = None br = None
# Note: check for != 0 not > 0, as we allow discounts to be expressed with < 0 if self.recurring_price != 0:
if self.one_time_price != 0 and self.billrecord_set.count() == 0: records = BillRecord.objects.filter(order=self).all()
if not records:
if self.one_time_price:
br = BillRecord.objects.create(bill=bill, br = BillRecord.objects.create(bill=bill,
order=self, order=self,
starting_date=self.starting_date, starting_date=self.starting_date,
ending_date=self.starting_date, ending_date=bill.ending_date,
is_recurring_record=False) is_recurring_record=False)
else:
if self.recurring_price != 0: br = self.create_new_bill_record_for_recurring_order(bill)
br = BillRecord.objects.filter(bill=bill, order=self, is_recurring_record=True).first() else:
opened_recurring_record = BillRecord.objects.filter(bill=bill, order=self, is_recurring_record=True).first()
if br: if opened_recurring_record:
br = opened_recurring_record
self.update_bill_record_for_recurring_order(br, bill) self.update_bill_record_for_recurring_order(br, bill)
else: else:
br = self.create_new_bill_record_for_recurring_order(bill) br = self.create_new_bill_record_for_recurring_order(bill)
return br return br
def update_bill_record_for_recurring_order(self, 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 the order has an ending date set, we might need to adjust the bill_record
if self.ending_date: if self.ending_date:
if bill_record_for_this_bill.ending_date != self.ending_date: if bill_record.ending_date != self.ending_date:
bill_record_for_this_bill.ending_date = self.ending_date bill_record.ending_date = self.ending_date
else: else:
# recurring, not terminated, should go until at least end of bill # recurring, not terminated, should go until at least end of bill
if bill_record_for_this_bill.ending_date < bill.ending_date: if bill_record.ending_date < bill.ending_date:
bill_record_for_this_bill.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): def create_new_bill_record_for_recurring_order(self, bill):
""" """
Create a new bill record Create a new bill record
""" """
last_bill_record = BillRecord.objects.filter(order=self).order_by('id').last()
last_bill_record = BillRecord.objects.filter(order=self, is_recurring_record=True).order_by('id').last()
starting_date=self.starting_date starting_date=self.starting_date
@ -892,7 +890,6 @@ class Order(models.Model):
return return
starting_date = start_after(last_bill_record.ending_date) starting_date = start_after(last_bill_record.ending_date)
ending_date = self.get_ending_date_for_bill(bill) ending_date = self.get_ending_date_for_bill(bill)
return BillRecord.objects.create(bill=bill, return BillRecord.objects.create(bill=bill,
@ -901,47 +898,27 @@ class Order(models.Model):
ending_date=ending_date, ending_date=ending_date,
is_recurring_record=True) is_recurring_record=True)
def calculate_prices_and_config(self): def calculate_recurring_price(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
try: try:
value = self.config['features'][feature] config = json.loads(self.config)
except (KeyError, TypeError): recurring_price = 0
value = self.product.config['features'][feature]['min'] 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 vat_rate = VATRate.get_vat_rate(self.billing_address)
max_val = self.product.config['features'][feature].get('max', value) 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,
if value < min_val or value > max_val: vat_rate=vat_rate * 100, vat_validation_status = vat_validation_status
raise ValidationError(f"Feature '{feature}' must be at least {min_val} and at maximum {max_val}. Value is: {value}") )
return price_after_discount_with_vat
one_time_price += self.product.config['features'][feature]['one_time_price_per_unit'] * value except Exception as e:
recurring_price += self.product.config['features'][feature]['recurring_price_per_unit'] * value logger.error("An error occurred while parsing the config obj", e)
config['features'][feature] = value return 0
return (one_time_price, recurring_price, config)
def check_parameters(self): def check_parameters(self):
if 'parameters' in self.product.config: if 'parameters' in self.product.config:
@ -955,7 +932,7 @@ class Order(models.Model):
# IMMUTABLE fields -- need to create new order to modify them # IMMUTABLE fields -- need to create new order to modify them
# However this is not enforced here... # However this is not enforced here...
if self._state.adding: 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: if self.recurring_period_id is None:
self.recurring_period = self.product.default_recurring_period self.recurring_period = self.product.default_recurring_period
@ -975,12 +952,7 @@ class Order(models.Model):
def __str__(self): def __str__(self):
try: return f"Order {self.id}: {self.description}"
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}"
class Bill(models.Model): class Bill(models.Model):
""" """
@ -1003,7 +975,7 @@ class Bill(models.Model):
# FIXME: editable=True -> is in the admin, but also editable in DRF # FIXME: editable=True -> is in the admin, but also editable in DRF
# Maybe filter fields in the serializer? # Maybe filter fields in the serializer?
is_final = models.BooleanField(default=False) is_closed = models.BooleanField(default=False)
class Meta: class Meta:
constraints = [ constraints = [
@ -1017,8 +989,9 @@ class Bill(models.Model):
""" """
Close/finish a bill Close/finish a bill
""" """
self.is_closed = True
self.is_final = True if not self.ending_date:
self.ending_date = timezone.now()
self.save() self.save()
@property @property
@ -1028,34 +1001,7 @@ class Bill(models.Model):
@property @property
def vat_rate(self): def vat_rate(self):
""" return VATRate.get_vat_rate(self.billing_address, when=self.ending_date)
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
@classmethod @classmethod
@ -1075,9 +1021,10 @@ class Bill(models.Model):
""" """
bills = [] bills = []
for billing_address in BillingAddress.objects.filter(owner=owner): 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 return bills
@ -1089,15 +1036,18 @@ class Bill(models.Model):
owner = billing_address.owner owner = billing_address.owner
all_orders = Order.objects.filter(owner=owner, all_orders = Order.objects.filter(Q(owner__id=owner.id), Q(should_be_billed=True),
billing_address=billing_address).order_by('id') 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) bill = cls.get_or_create_bill(billing_address, ending_date=ending_date)
for order in all_orders: for order in all_orders:
order.create_bill_record(bill) order.create_bill_record(bill)
return bill return bill
else:
# This Customer Hasn't any active orders
return False
@classmethod @classmethod
@ -1117,7 +1067,7 @@ class Bill(models.Model):
# Get date & bill from previous bill, if it exists # Get date & bill from previous bill, if it exists
if last_bill: if last_bill:
if not last_bill.is_final: if not last_bill.is_closed:
bill = last_bill bill = last_bill
starting_date = last_bill.starting_date starting_date = last_bill.starting_date
ending_date = bill.ending_date ending_date = bill.ending_date
@ -1167,9 +1117,11 @@ class BillRecord(models.Model):
if not self.is_recurring_record: if not self.is_recurring_record:
return 1 return 1
record_delta = self.ending_date - self.starting_date record_delta = self.ending_date.date() - self.starting_date.date()
if self.order.recurring_period and self.order.recurring_period.duration_seconds > 0:
return record_delta.total_seconds()/self.order.recurring_period.duration_seconds return int(record_delta.total_seconds() / self.order.recurring_period.duration_seconds)
else:
return 1
@property @property
def sum(self): def sum(self):

View file

@ -1,9 +1,5 @@
from django.utils import timezone from django.utils import timezone
from django.db import transaction 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 * from .models import *
def get_payments_for_user(user): def get_payments_for_user(user):
@ -12,12 +8,11 @@ def get_payments_for_user(user):
return sum(payments) return sum(payments)
def get_spendings_for_user(user): def get_spendings_for_user(user):
orders = Order.objects.filter(owner=user) bills = Bill.objects.filter(owner=user)
amount = 0 amount = 0
for order in orders: for bill in bills:
amount += order.one_time_price amount += bill.sum
amount += order.recurring_price * order.count_used(when=timezone.now())
return amount return amount
@ -25,34 +20,12 @@ def get_spendings_for_user(user):
def get_balance_for_user(user): def get_balance_for_user(user):
return get_payments_for_user(user) - get_spendings_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): def get_billing_address_for_user(user):
return BillingAddress.objects.get(owner=user, active=True) return BillingAddress.objects.filter(owner=user, active=True).first()
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

View file

@ -86,8 +86,9 @@ class OrderSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = Order model = Order
read_only_fields = ['replaced_by', 'depends_on'] read_only_fields = ['replaced_by', 'depends_on']
fields = ['uuid', 'owner', 'description', 'creation_date', 'starting_date', 'ending_date', fields = ['owner', 'description', 'creation_date', 'starting_date', 'ending_date',
'bill', 'recurring_period', 'recurring_price', 'one_time_price'] + read_only_fields '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: class Meta:
model = Bill model = Bill
fields = ['uuid', 'reference', 'owner', 'amount', 'vat_amount', 'total', fields = ['owner', 'sum', 'vat_rate',
'due_date', 'creation_date', 'starting_date', 'ending_date', '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 # We do not want users to mutate the country / VAT number of an address, as it
# will change VAT on existing bills. # will change VAT on existing bills.
class UpdateBillingAddressSerializer(serializers.ModelSerializer): class UpdateBillingAddressSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = BillingAddress 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 from django.utils import timezone

View file

@ -9,6 +9,8 @@ from django.contrib.auth import get_user_model
from .models import StripeCustomer, StripeCreditCard from .models import StripeCustomer, StripeCreditCard
logger = logging.getLogger(__name__)
CURRENCY = 'chf' CURRENCY = 'chf'
stripe.api_key = settings.STRIPE_KEY stripe.api_key = settings.STRIPE_KEY
@ -77,9 +79,24 @@ def create_setup_intent(customer_id):
def get_setup_intent(setup_intent_id): def get_setup_intent(setup_intent_id):
return stripe.SetupIntent.retrieve(setup_intent_id) return stripe.SetupIntent.retrieve(setup_intent_id)
@handle_stripe_error
def get_payment_method(payment_method_id): def get_payment_method(payment_method_id):
return stripe.PaymentMethod.retrieve(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 @handle_stripe_error
def create_customer(name, email): def create_customer(name, email):
return stripe.Customer.create(name=name, email=email) return stripe.Customer.create(name=name, email=email)
@ -142,7 +159,7 @@ def sync_cards_for_user(user):
) )
@handle_stripe_error @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. # Amount is in CHF but stripes requires smallest possible unit.
# https://stripe.com/docs/api/payment_intents/create#create_payment_intent-amount # https://stripe.com/docs/api/payment_intents/create#create_payment_intent-amount
# FIXME: might need to be adjusted for other currencies # 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") return Exception("Programming error: unsupported currency")
try: try:
card = StripeCreditCard.objects.get(owner=user, card = card or StripeCreditCard.objects.get(owner=user,
active=True) active=True)
except StripeCreditCard.DoesNotExist: except StripeCreditCard.DoesNotExist:
@ -169,3 +186,64 @@ def charge_customer(user, amount, currency='CHF'):
off_session=True, off_session=True,
confirm=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' %} {% extends 'uncloud/base.html' %}
{% block bootstrap5_extra_head %} {% 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> <script src="https://js.stripe.com/v3/"></script>
{% endblock %} {% endblock %}
{% block bootstrap5_content %} {% block bootstrap5_content %}
<div class="container"> <div class="container">
{% csrf_token %}
<div id="content"> <div id="content">
<h1>Register Credit Card with Stripe</h1> <h1>Register Credit Card with Stripe</h1>
<p> <p>
@ -18,10 +17,15 @@
<span id="message"></span> <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-element"></div>
<div id="card-errors" role="alert"></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="ungleichmessage">The card will be registered with stripe.</div>
<div id="goback" style="display: none;"> <div id="goback" style="display: none;">
@ -32,28 +36,30 @@
<!-- Enable Stripe from UI elements - standard code --> <!-- Enable Stripe from UI elements - standard code -->
<script> <script>
var cardholderName = document.getElementById('cardholder-name');
var setupForm = document.getElementById('setup-form');
var clientSecret = setupForm.dataset.secret;
var stripe = Stripe('{{ stripe_pk }}'); 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 cardButton = document.getElementById('card-button');
var messageContainer = document.getElementById('message'); var messageContainer = document.getElementById('message');
var backmessage = document.getElementById('goback'); var backmessage = document.getElementById('goback');
var clientSecret = '{{ client_secret }}'; var clientSecret = '{{ client_secret }}';
cardButton.addEventListener('click', function(ev) { var elements = stripe.elements();
document.getElementById("ungleichmessage").innerHTML var cardElement = elements.create('card');
= "Registering card with Stripe, please wait ..."; cardElement.mount('#card-element');
setupForm.addEventListener('submit', function(ev) {
ev.preventDefault();
stripe.confirmCardSetup( stripe.confirmCardSetup(
clientSecret, clientSecret,
{ {
payment_method: { payment_method: {
card: cardElement, card: cardElement,
billing_details: { name: "{{username}}", }, billing_details: {
name: cardholderName.value,
},
}, },
} }
).then(function(result) { ).then(function(result) {
@ -61,15 +67,12 @@
var message = document.createTextNode('Error:' + result.error.message); var message = document.createTextNode('Error:' + result.error.message);
messageContainer.appendChild(message); messageContainer.appendChild(message);
} else { } else {
// Return to API on success.
document.getElementById("ungleichmessage").innerHTML document.getElementById("ungleichmessage").innerHTML
= "Registered credit card with Stripe." = "Registered credit card with Stripe."
backmessage.style.display = "block"; backmessage.style.display = "block";
// Return to API on success.
} }
}); });
}); });
</script> </script>
{% endblock %} {% endblock %}

View file

@ -5,52 +5,28 @@ from django.utils import timezone
from .models import * from .models import *
from uncloud_service.models import GenericServiceProduct from uncloud_service.models import GenericServiceProduct
from uncloud.models import UncloudProvider from uncloud.models import UncloudProvider, UncloudNetwork
import json 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 = { vm_product_config = {
'features': { 'features': {
'cores': 'cores':
{ 'min': 1, { 'min': 1,
'max': 48, 'max': 48
'one_time_price_per_unit': 0,
'recurring_price_per_unit': 4
}, },
'ram_gb': 'ram_gb':
{ 'min': 1, { 'min': 1,
'max': 256, 'max': 256
'one_time_price_per_unit': 0,
'recurring_price_per_unit': 4
}, },
}, },
} }
vm_order_config = { vm_order_config = json.dumps({
'features': { 'cores': 1,
'cores': 2, 'memory': 2,
'ram_gb': 2 'storage': 100
} })
}
vm_order_downgrade_config = { vm_order_downgrade_config = {
'features': { 'features': {
@ -92,7 +68,6 @@ class ProductTestCase(TestCase):
""" """
Create a sample product Create a sample product
""" """
p = Product.objects.create(name="Testproduct", p = Product.objects.create(name="Testproduct",
description="Only for testing", description="Only for testing",
config=vm_product_config) config=vm_product_config)
@ -107,6 +82,8 @@ class OrderTestCase(TestCase):
""" """
def setUp(self): 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( self.user = get_user_model().objects.create(
username='random_user', username='random_user',
email='jane.random@domain.tld') 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 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, o = Order.objects.create(owner=self.user,
billing_address=self.ba, billing_address=self.ba,
pricing_plan = self.pricing_plan,
product=self.product, product=self.product,
config=vm_order_config) config=order_config)
def test_order_product(self): def test_order_product(self):
""" """
Order a product, ensure the order has correct price setup 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, o = Order.objects.create(owner=self.user,
billing_address=self.ba, 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.one_time_price, 0)
self.assertEqual(o.recurring_price, 16) self.assertEqual(o.recurring_price, 13.0)
def test_change_order(self): def test_change_order(self):
""" """
@ -159,14 +148,19 @@ class OrderTestCase(TestCase):
- a new order is created - a new order is created
- the price is correct in the new order - 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, order1 = Order.objects.create(owner=self.user,
billing_address=self.ba, billing_address=self.ba,
pricing_plan = self.pricing_plan,
product=self.product, product=self.product,
config=vm_order_config) config=order_config)
self.assertEqual(order1.one_time_price, 0) self.assertEqual(order1.one_time_price, 0)
self.assertEqual(order1.recurring_price, 16) self.assertEqual(order1.recurring_price, 26.0)
class ModifyOrderTestCase(TestCase): class ModifyOrderTestCase(TestCase):
@ -181,7 +175,18 @@ class ModifyOrderTestCase(TestCase):
self.user = get_user_model().objects.create( self.user = get_user_model().objects.create(
username='random_user', username='random_user',
email='jane.random@domain.tld') 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( self.ba = BillingAddress.objects.create(
owner=self.user, owner=self.user,
organization = 'Test org', organization = 'Test org',
@ -226,10 +231,11 @@ class ModifyOrderTestCase(TestCase):
order1 = Order.objects.create(owner=self.user, order1 = Order.objects.create(owner=self.user,
billing_address=BillingAddress.get_address_for(self.user), billing_address=BillingAddress.get_address_for(self.user),
product=self.product, product=self.product,
config=vm_order_config, config=self.order1_config,
pricing_plan=self.pricing_plan,
starting_date=starting_date) 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) 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) first_order_should_end_at = starting_date + datetime.timedelta(days=30)
change1_date = start_after(starting_date + datetime.timedelta(days=15)) change1_date = start_after(starting_date + datetime.timedelta(days=15))
bill_ending_date = change1_date + datetime.timedelta(days=1) bill_ending_date = change1_date + datetime.timedelta(days=1)
order1 = Order.objects.create(owner=self.user, order1 = Order.objects.create(owner=self.user,
billing_address=BillingAddress.get_address_for(self.user), billing_address=BillingAddress.get_address_for(self.user),
product=self.product, product=self.product,
config=vm_order_config, pricing_plan=self.pricing_plan,
config=self.order1_config,
starting_date=starting_date) 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) bills = Bill.create_next_bills_for_user(user, ending_date=bill_ending_date)
bill = bills[0] bill = bills[0]
bill_records = BillRecord.objects.filter(bill=bill) 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].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): class BillTestCase(TestCase):
@ -298,6 +306,9 @@ class BillTestCase(TestCase):
def setUp(self): def setUp(self):
RecurringPeriod.populate_db_defaults() 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( self.user_without_address = get_user_model().objects.create(
username='no_home_person', username='no_home_person',
email='far.away@domain.tld') email='far.away@domain.tld')
@ -331,12 +342,12 @@ class BillTestCase(TestCase):
'starting_date': timezone.make_aware(datetime.datetime(2020,3,3)), 'starting_date': timezone.make_aware(datetime.datetime(2020,3,3)),
'ending_date': timezone.make_aware(datetime.datetime(2020,4,17)), 'ending_date': timezone.make_aware(datetime.datetime(2020,4,17)),
'price': 15, '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", 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", 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.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 }) through_defaults= { 'is_default': True })
self.vm.recurring_periods.add(self.default_recurring_period, 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( return Order.objects.create(
owner=self.user, owner=self.user,
recurring_period=RecurringPeriod.objects.get(name="Onetime"), recurring_period=RecurringPeriod.objects.get(name="Onetime"),
product=self.chocolate, product=self.product,
billing_address=BillingAddress.get_address_for(self.user), billing_address=BillingAddress.get_address_for(self.user),
starting_date=self.order_meta[1]['starting_date'], starting_date=self.order_meta[1]['starting_date'],
ending_date=self.order_meta[1]['ending_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): def order_vm(self, owner=None):
@ -383,27 +395,52 @@ class BillTestCase(TestCase):
owner=owner, owner=owner,
product=self.vm, product=self.vm,
config=vm_order_config, config=vm_order_config,
pricing_plan=self.pricing_plan,
billing_address=BillingAddress.get_address_for(self.recurring_user), billing_address=BillingAddress.get_address_for(self.recurring_user),
starting_date=timezone.make_aware(datetime.datetime(2020,3,3)), 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, owner=self.user,
recurring_period=RecurringPeriod.objects.get(name="Onetime"), product=self.vm,
product=self.chocolate, config=vm_order_config,
pricing_plan=self.pricing_plan,
one_time_price = 35,
billing_address=BillingAddress.get_address_for(self.user), billing_address=BillingAddress.get_address_for(self.user),
starting_date=self.order_meta[1]['starting_date'], starting_date=timezone.make_aware(datetime.datetime(2020,3,3)),
ending_date=self.order_meta[1]['ending_date'], )
config=chocolate_order_config)
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): def test_bill_one_time_one_bill_record(self):
""" """
Ensure there is only 1 bill record per order 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) 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 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) 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): def test_bill_creates_record_for_recurring_order(self):
@ -461,7 +503,7 @@ class BillingAddressTestCase(TestCase):
Raise an error, when there is no address Raise an error, when there is no address
""" """
self.assertRaises(uncloud_pay.models.BillingAddress.DoesNotExist, self.assertRaises(BillingAddress.DoesNotExist,
BillingAddress.get_address_for, BillingAddress.get_address_for,
self.user) self.user)
@ -479,6 +521,7 @@ class VATRatesTestCase(TestCase):
postal_code="unknown", postal_code="unknown",
active=True) active=True)
UncloudNetwork.populate_db_defaults()
UncloudProvider.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.contrib.auth.mixins import LoginRequiredMixin
from django.views.generic.base import TemplateView from django.views.generic.base import TemplateView
from django.shortcuts import render from django.shortcuts import render
from django.db import transaction from django.db import transaction
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
@ -29,27 +27,31 @@ from .selectors import *
from datetime import datetime from datetime import datetime
from vat_validator import sanitize_vat from vat_validator import sanitize_vat
import uncloud_pay.stripe as uncloud_stripe 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__) logger = logging.getLogger(__name__)
### ###
# 2020-12 checked code # 2020-12 checked code
class RegisterCard(LoginRequiredMixin, TemplateView): class RegisterCard(TemplateView):
login_url = '/login/'
template_name = "uncloud_pay/register_stripe.html" 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): def get_context_data(self, **kwargs):
customer_id = uncloud_stripe.get_customer_id_for(self.request.user) customer_id = uncloud_stripe.get_customer_id_for(self.request.user)
setup_intent = uncloud_stripe.create_setup_intent(customer_id) setup_intent = uncloud_stripe.create_setup_intent(customer_id)
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
context['client_secret'] = setup_intent.client_secret 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 context['stripe_pk'] = uncloud_stripe.public_api_key
return context return context
@ -70,7 +72,6 @@ class CreditCardViewSet(mixins.RetrieveModelMixin,
def get_queryset(self): def get_queryset(self):
return StripeCreditCard.objects.filter(owner=self.request.user) return StripeCreditCard.objects.filter(owner=self.request.user)
class PaymentViewSet(viewsets.ModelViewSet): class PaymentViewSet(viewsets.ModelViewSet):
serializer_class = PaymentSerializer serializer_class = PaymentSerializer
permission_classes = [permissions.IsAuthenticated] permission_classes = [permissions.IsAuthenticated]
@ -89,24 +90,13 @@ class BalanceViewSet(viewsets.ViewSet):
return Response(serializer.data) return Response(serializer.data)
### class ListCards(TemplateView):
# 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/'
template_name = "uncloud_pay/list_stripe.html" 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): def get_context_data(self, **kwargs):
customer_id = uncloud_stripe.get_customer_id_for(self.request.user) customer_id = uncloud_stripe.get_customer_id_for(self.request.user)
cards = uncloud_stripe.get_customer_cards(customer_id) cards = uncloud_stripe.get_customer_cards(customer_id)
@ -117,140 +107,6 @@ class ListCards(LoginRequiredMixin, TemplateView):
return context 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. # Bills and Orders.
@ -314,7 +170,7 @@ class BillingAddressViewSet(mixins.CreateModelMixin,
return BillingAddressSerializer return BillingAddressSerializer
def get_queryset(self): def get_queryset(self):
return self.request.user.billingaddress_set.all() return self.request.user.billing_addresses.all()
def create(self, request): def create(self, request):
serializer = self.get_serializer(data=request.data) serializer = self.get_serializer(data=request.data)