Merge branch 'feature/monthly-invoices' into 'master'
Feature/monthly invoices See merge request ungleich-public/dynamicweb!689
This commit is contained in:
commit
c9fb034ebd
12 changed files with 883 additions and 3 deletions
|
@ -648,6 +648,7 @@ CELERY_RESULT_SERIALIZER = 'json'
|
|||
CELERY_MAX_RETRIES = int_env('CELERY_MAX_RETRIES', 5)
|
||||
|
||||
DCL_ERROR_EMAILS_TO = env('DCL_ERROR_EMAILS_TO')
|
||||
ADMIN_EMAIL = env('ADMIN_EMAIL')
|
||||
|
||||
DCL_ERROR_EMAILS_TO_LIST = []
|
||||
if DCL_ERROR_EMAILS_TO is not None:
|
||||
|
|
52
hosting/management/commands/fetch_stripe_bills.py
Normal file
52
hosting/management/commands/fetch_stripe_bills.py
Normal file
|
@ -0,0 +1,52 @@
|
|||
import logging
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from hosting.models import MonthlyHostingBill
|
||||
from membership.models import CustomUser
|
||||
from utils.stripe_utils import StripeUtils
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = '''Fetches invoices from Stripe and creates bills for a given
|
||||
customer in the MonthlyHostingBill model'''
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument('customer_email', nargs='+', type=str)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
try:
|
||||
for email in options['customer_email']:
|
||||
stripe_utils = StripeUtils()
|
||||
user = CustomUser.objects.get(email=email)
|
||||
if hasattr(user, 'stripecustomer'):
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
'Found %s. Fetching bills for him.' % email))
|
||||
mhb = MonthlyHostingBill.objects.filter(
|
||||
customer=user.stripecustomer).last()
|
||||
created_gt = {}
|
||||
if mhb is not None:
|
||||
# fetch only invoices which is created after
|
||||
# mhb.created, because we already have invoices till
|
||||
# this date
|
||||
created_gt = int(mhb.created.timestamp())
|
||||
|
||||
all_invoices_response = stripe_utils.get_all_invoices(
|
||||
user.stripecustomer.stripe_id,
|
||||
created_gt=created_gt
|
||||
)
|
||||
if all_invoices_response['error'] is not None:
|
||||
self.stdout.write(self.style.ERROR(all_invoices_response['error']))
|
||||
exit(1)
|
||||
all_invoices = all_invoices_response['response_object']
|
||||
self.stdout.write(self.style.SUCCESS("Obtained {} invoices".format(len(all_invoices) if all_invoices is not None else 0)))
|
||||
for invoice in all_invoices:
|
||||
invoice['customer'] = user.stripecustomer
|
||||
MonthlyHostingBill.create(invoice)
|
||||
else:
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
'Customer email %s does not have a stripe customer.' % email))
|
||||
except Exception as e:
|
||||
print(" *** Error occurred. Details {}".format(str(e)))
|
42
hosting/migrations/0050_monthlyhostingbill.py
Normal file
42
hosting/migrations/0050_monthlyhostingbill.py
Normal file
|
@ -0,0 +1,42 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.9.4 on 2019-04-03 03:47
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import utils.mixins
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('membership', '0007_auto_20180213_0128'),
|
||||
('hosting', '0049_auto_20181005_0736'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='MonthlyHostingBill',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created', models.DateTimeField(help_text='When the invoice was created')),
|
||||
('receipt_number', models.CharField(help_text='The receipt number that is generated on Stripe', max_length=100)),
|
||||
('invoice_number', models.CharField(help_text='The invoice number that is generated on Stripe', max_length=100)),
|
||||
('paid_at', models.DateTimeField(help_text='Date on which the bill was paid')),
|
||||
('period_start', models.DateTimeField()),
|
||||
('period_end', models.DateTimeField()),
|
||||
('billing_reason', models.CharField(max_length=25)),
|
||||
('discount', models.PositiveIntegerField()),
|
||||
('total', models.IntegerField()),
|
||||
('lines_data_count', models.IntegerField()),
|
||||
('invoice_id', models.CharField(max_length=100, unique=True)),
|
||||
('lines_meta_data_csv', models.TextField()),
|
||||
('customer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='membership.StripeCustomer')),
|
||||
('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='hosting.HostingOrder')),
|
||||
],
|
||||
options={
|
||||
'permissions': (('view_monthlyhostingbill', 'View Monthly Hosting'),),
|
||||
},
|
||||
bases=(utils.mixins.AssignPermissionsMixin, models.Model),
|
||||
),
|
||||
]
|
25
hosting/migrations/0051_auto_20190403_0703.py
Normal file
25
hosting/migrations/0051_auto_20190403_0703.py
Normal file
|
@ -0,0 +1,25 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.9.4 on 2019-04-03 07:03
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('hosting', '0050_monthlyhostingbill'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='monthlyhostingbill',
|
||||
name='subscription_ids_csv',
|
||||
field=models.TextField(default=''),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='monthlyhostingbill',
|
||||
name='lines_meta_data_csv',
|
||||
field=models.TextField(default=''),
|
||||
),
|
||||
]
|
37
hosting/migrations/0052_hostingbilllineitem.py
Normal file
37
hosting/migrations/0052_hostingbilllineitem.py
Normal file
|
@ -0,0 +1,37 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.9.4 on 2019-04-13 11:38
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import utils.mixins
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('hosting', '0051_auto_20190403_0703'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='HostingBillLineItem',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('amount', models.PositiveSmallIntegerField()),
|
||||
('description', models.CharField(max_length=255)),
|
||||
('discountable', models.BooleanField()),
|
||||
('metadata', models.CharField(max_length=128)),
|
||||
('period_start', models.DateTimeField()),
|
||||
('period_end', models.DateTimeField()),
|
||||
('proration', models.BooleanField()),
|
||||
('quantity', models.PositiveIntegerField()),
|
||||
('unit_amount', models.PositiveIntegerField()),
|
||||
('monthly_hosting_bill', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='hosting.MonthlyHostingBill')),
|
||||
],
|
||||
options={
|
||||
'permissions': (('view_hostingbilllineitem', 'View Monthly Hosting Bill Line Item'),),
|
||||
},
|
||||
bases=(utils.mixins.AssignPermissionsMixin, models.Model),
|
||||
),
|
||||
]
|
|
@ -1,8 +1,11 @@
|
|||
import json
|
||||
import logging
|
||||
import os
|
||||
import pytz
|
||||
|
||||
from Crypto.PublicKey import RSA
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from datetime import datetime
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
from django.utils.functional import cached_property
|
||||
|
@ -232,6 +235,207 @@ class HostingBill(AssignPermissionsMixin, models.Model):
|
|||
return instance
|
||||
|
||||
|
||||
class MonthlyHostingBill(AssignPermissionsMixin, models.Model):
|
||||
"""
|
||||
Corresponds to Invoice object of Stripe
|
||||
"""
|
||||
customer = models.ForeignKey(StripeCustomer)
|
||||
order = models.ForeignKey(HostingOrder)
|
||||
created = models.DateTimeField(help_text="When the invoice was created")
|
||||
receipt_number = models.CharField(
|
||||
help_text="The receipt number that is generated on Stripe",
|
||||
max_length=100
|
||||
)
|
||||
invoice_number = models.CharField(
|
||||
help_text="The invoice number that is generated on Stripe",
|
||||
max_length=100
|
||||
)
|
||||
paid_at = models.DateTimeField(help_text="Date on which the bill was paid")
|
||||
period_start = models.DateTimeField()
|
||||
period_end = models.DateTimeField()
|
||||
billing_reason = models.CharField(max_length=25)
|
||||
discount = models.PositiveIntegerField()
|
||||
total = models.IntegerField()
|
||||
lines_data_count = models.IntegerField()
|
||||
invoice_id = models.CharField(unique=True, max_length=100)
|
||||
lines_meta_data_csv = models.TextField(default="")
|
||||
subscription_ids_csv = models.TextField(default="")
|
||||
|
||||
permissions = ('view_monthlyhostingbill',)
|
||||
|
||||
class Meta:
|
||||
permissions = (
|
||||
('view_monthlyhostingbill', 'View Monthly Hosting'),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def create(cls, args):
|
||||
# Try to infer the HostingOrder from subscription id or VM_ID
|
||||
if len(args['subscription_ids_csv']) > 0:
|
||||
sub_ids = [sub_id.strip() for sub_id in args['subscription_ids_csv'].split(",")]
|
||||
if len(sub_ids) == 1:
|
||||
args['order'] = HostingOrder.objects.get(
|
||||
subscription_id=sub_ids[0]
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
"More than one subscriptions"
|
||||
"for MonthlyHostingBill {}".format(args['invoice_id'])
|
||||
)
|
||||
logger.debug("SUB_IDS=".format(','.join(sub_ids)))
|
||||
logger.debug("Not importing invoices")
|
||||
return
|
||||
elif len(args['lines_meta_data_csv']) > 0:
|
||||
vm_ids = [vm_id.strip() for vm_id in args['lines_meta_data_csv'].split(",")]
|
||||
if len(vm_ids) == 1:
|
||||
args['order'] = HostingOrder.objects.get(vm_id=vm_ids[0])
|
||||
else:
|
||||
logger.debug(
|
||||
"More than one VM_ID"
|
||||
"for MonthlyHostingBill {}".format(args['invoice_id'])
|
||||
)
|
||||
logger.debug("VM_IDS=".format(','.join(vm_ids)))
|
||||
logger.debug("Not importing invoices")
|
||||
return
|
||||
else:
|
||||
logger.debug("Neither subscription id nor vm_id available")
|
||||
logger.debug("Can't import invoice")
|
||||
return
|
||||
|
||||
instance = cls.objects.create(
|
||||
created=datetime.utcfromtimestamp(
|
||||
args['created']).replace(tzinfo=pytz.utc),
|
||||
receipt_number=(
|
||||
args['receipt_number']
|
||||
if args['receipt_number'] is not None else ''
|
||||
),
|
||||
invoice_number=(
|
||||
args['invoice_number']
|
||||
if args['invoice_number'] is not None else ''
|
||||
),
|
||||
paid_at=datetime.utcfromtimestamp(
|
||||
args['paid_at']).replace(tzinfo=pytz.utc),
|
||||
period_start=datetime.utcfromtimestamp(
|
||||
args['period_start']).replace(tzinfo=pytz.utc),
|
||||
period_end=datetime.utcfromtimestamp(
|
||||
args['period_end']).replace(tzinfo=pytz.utc),
|
||||
billing_reason=args['billing_reason'],
|
||||
discount=args['discount'],
|
||||
total=args['total'],
|
||||
lines_data_count=args['lines_data_count'],
|
||||
invoice_id=args['invoice_id'],
|
||||
lines_meta_data_csv=args['lines_meta_data_csv'],
|
||||
customer=args['customer'],
|
||||
order=args['order'],
|
||||
subscription_ids_csv=args['subscription_ids_csv'],
|
||||
)
|
||||
|
||||
if 'line_items' in args:
|
||||
line_items = args['line_items']
|
||||
for item in line_items:
|
||||
line_item_instance = HostingBillLineItem.objects.create(
|
||||
monthly_hosting_bill=instance,
|
||||
amount=item.amount,
|
||||
# description seems to be set to null in the Stripe
|
||||
# response for an invoice
|
||||
description="" if item.description is None else item.description,
|
||||
discountable=item.discountable,
|
||||
metadata=json.dumps(item.metadata),
|
||||
period_start=datetime.utcfromtimestamp(item.period.start).replace(tzinfo=pytz.utc), period_end=datetime.utcfromtimestamp(item.period.end).replace(tzinfo=pytz.utc),
|
||||
proration=item.proration,
|
||||
quantity=item.quantity,
|
||||
# Strange that line item does not have unit_amount but api
|
||||
# states that it is present
|
||||
# https://stripe.com/docs/api/invoiceitems/object#invoiceitem_object-unit_amount
|
||||
# So, for the time being I set the unit_amount to 0 if not
|
||||
# found in the line item
|
||||
unit_amount=item.unit_amount if hasattr(item, "unit_amount") else 0
|
||||
)
|
||||
line_item_instance.assign_permissions(instance.customer.user)
|
||||
instance.assign_permissions(instance.customer.user)
|
||||
return instance
|
||||
|
||||
def total_in_chf(self):
|
||||
"""
|
||||
Returns amount in chf. The total amount in this model is in cents.
|
||||
Hence we multiply it by 0.01 to obtain the result
|
||||
|
||||
:return:
|
||||
"""
|
||||
return self.total * 0.01
|
||||
|
||||
def discount_in_chf(self):
|
||||
"""
|
||||
Returns discount in chf.
|
||||
|
||||
:return:
|
||||
"""
|
||||
return self.discount * 0.01
|
||||
|
||||
def get_vm_id(self):
|
||||
"""
|
||||
Returns the VM_ID metadata if set in this MHB else returns None
|
||||
:return:
|
||||
"""
|
||||
return_value = None
|
||||
if len(self.lines_meta_data_csv) > 0:
|
||||
vm_ids = [vm_id.strip() for vm_id in
|
||||
self.lines_meta_data_csv.split(",")]
|
||||
if len(vm_ids) == 1:
|
||||
return vm_ids[0]
|
||||
else:
|
||||
logger.debug(
|
||||
"More than one VM_ID"
|
||||
"for MonthlyHostingBill {}".format(self.invoice_id)
|
||||
)
|
||||
logger.debug("VM_IDS=".format(','.join(vm_ids)))
|
||||
return return_value
|
||||
|
||||
def get_period_start(self):
|
||||
"""
|
||||
Return the period start of the invoice for the line items
|
||||
:return:
|
||||
"""
|
||||
items = HostingBillLineItem.objects.filter(monthly_hosting_bill=self)
|
||||
if len(items) > 0:
|
||||
return items[0].period_start
|
||||
else:
|
||||
return self.period_start
|
||||
|
||||
def get_period_end(self):
|
||||
"""
|
||||
Return the period end of the invoice for the line items
|
||||
:return:
|
||||
"""
|
||||
items = HostingBillLineItem.objects.filter(monthly_hosting_bill=self)
|
||||
if len(items) > 0:
|
||||
return items[0].period_end
|
||||
else:
|
||||
return self.period_end
|
||||
|
||||
|
||||
class HostingBillLineItem(AssignPermissionsMixin, models.Model):
|
||||
"""
|
||||
Corresponds to InvoiceItem object of Stripe
|
||||
"""
|
||||
monthly_hosting_bill = models.ForeignKey(MonthlyHostingBill)
|
||||
amount = models.PositiveSmallIntegerField()
|
||||
description = models.CharField(max_length=255)
|
||||
discountable = models.BooleanField()
|
||||
metadata = models.CharField(max_length=128)
|
||||
period_start = models.DateTimeField()
|
||||
period_end = models.DateTimeField()
|
||||
proration = models.BooleanField()
|
||||
quantity = models.PositiveIntegerField()
|
||||
unit_amount = models.PositiveIntegerField()
|
||||
permissions = ('view_hostingbilllineitem',)
|
||||
|
||||
class Meta:
|
||||
permissions = (
|
||||
('view_hostingbilllineitem', 'View Monthly Hosting Bill Line Item'),
|
||||
)
|
||||
|
||||
|
||||
class VMDetail(models.Model):
|
||||
user = models.ForeignKey(CustomUser)
|
||||
vm_id = models.IntegerField(default=0)
|
||||
|
|
|
@ -26,7 +26,7 @@
|
|||
<img class="svg-img" src="{% static 'hosting/img/key.svg' %}">
|
||||
</div>
|
||||
</a>
|
||||
<a href="{% url 'hosting:orders' %}" class="hosting-dashboard-item">
|
||||
<a href="{% if has_invoices %}{% url 'hosting:invoices' %}{% else %}{% url 'hosting:orders' %}{% endif %}" class="hosting-dashboard-item">
|
||||
<h2>{% trans "My Bills" %}</h2>
|
||||
<div class="hosting-dashboard-image">
|
||||
<img class="svg-img" src="{% static 'hosting/img/billing.svg' %}">
|
||||
|
|
224
hosting/templates/hosting/invoice_detail.html
Normal file
224
hosting/templates/hosting/invoice_detail.html
Normal file
|
@ -0,0 +1,224 @@
|
|||
{% extends "hosting/base_short.html" %}
|
||||
{% load staticfiles bootstrap3 humanize i18n custom_tags %}
|
||||
|
||||
|
||||
{% block content %}
|
||||
<div id="order-detail{{invoice.invoice_number}}" 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">
|
||||
<img src="{% static 'hosting/img/billing.svg' %}" class="un-icon">
|
||||
{% blocktrans with page_header_text=page_header_text|default:"Invoice" %}{{page_header_text}}{% endblocktrans %}
|
||||
</h1>
|
||||
{% if invoice %}
|
||||
<div class="dashboard-container-options">
|
||||
<button type="button" class="btn-plain btn-pdf"
|
||||
data-target="#order-detail{{invoice.invoice_number}}"><img
|
||||
src="{% static 'hosting/img/icon-pdf.svg' %}"
|
||||
class="svg-img"></button>
|
||||
<button type="button" class="btn-plain btn-print"><img
|
||||
src="{% static 'hosting/img/icon-print.svg' %}"
|
||||
class="svg-img"></button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="order-details">
|
||||
{% if invoice %}
|
||||
<p>
|
||||
<strong>{% trans "Invoice #" %} {{invoice.invoice_number}}</strong>
|
||||
</p>
|
||||
{% endif %}
|
||||
<p>
|
||||
<strong>{% trans "Date" %}:</strong>
|
||||
<span class="locale_date">
|
||||
{% if invoice %}
|
||||
{{invoice.paid_at|date:'Y-m-d h:i a'}}
|
||||
{% else %}
|
||||
{% now "Y-m-d h:i a" %}
|
||||
{% endif %}
|
||||
</span>
|
||||
</p>
|
||||
{% if invoice and vm %}
|
||||
<p>
|
||||
<strong>{% trans "Status" %}: </strong>
|
||||
<strong>
|
||||
{% if vm.terminated_at %}
|
||||
<span class="vm-color-failed">{% trans "Terminated" %}</span>
|
||||
{% elif invoice.order.status == 'Approved' %}
|
||||
<span class="vm-color-online">{% trans "Approved" %}</span>
|
||||
{% else %}
|
||||
<span class="vm-status-failed">{% trans "Declined" %}</span>
|
||||
{% endif %}
|
||||
</strong>
|
||||
</p>
|
||||
{% endif %}
|
||||
<hr>
|
||||
<div>
|
||||
<address>
|
||||
<h4>{% trans "Billed to" %}:</h4>
|
||||
<p>
|
||||
{% if invoice.order %}
|
||||
{{invoice.customer.user.name}}<br>
|
||||
{{invoice.order.billing_address.street_address}},
|
||||
{{invoice.order.billing_address.postal_code}}<br>
|
||||
{{invoice.order.billing_address.city}},
|
||||
{{invoice.order.billing_address.country}}
|
||||
{% endif %}
|
||||
</p>
|
||||
</address>
|
||||
</div>
|
||||
<hr>
|
||||
<div>
|
||||
<h4>{% trans "Payment method" %}:</h4>
|
||||
<p>
|
||||
{% if invoice.order %}
|
||||
{{invoice.order.cc_brand}} {% trans "ending in" %} ****
|
||||
{{invoice.order.last4}}<br>
|
||||
{{invoice.customer.user.email}}
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
<hr>
|
||||
<div>
|
||||
<h4>{% trans "Invoice summary" %}</h4>
|
||||
{% if vm %}
|
||||
<p>
|
||||
<strong>{% trans "Product" %}:</strong>
|
||||
{% if vm.name %}
|
||||
{{ vm.name }}
|
||||
{% endif %}
|
||||
</p>
|
||||
<div class="row">
|
||||
<div class="col-sm-6">
|
||||
{% if period_start %}
|
||||
<p>
|
||||
<span>{% trans "Period" %}: </span>
|
||||
<span>
|
||||
<span class="locale_date"
|
||||
data-format="YYYY/MM/DD">{{ period_start|date:'Y-m-d h:i a' }}</span> - <span
|
||||
class="locale_date" data-format="YYYY/MM/DD">{{ period_end|date:'Y-m-d h:i a' }}</span>
|
||||
</span>
|
||||
</p>
|
||||
{% endif %}
|
||||
<p>
|
||||
<span>{% trans "Cores" %}: </span>
|
||||
{% if vm.cores %}
|
||||
<strong class="pull-right">{{vm.cores|floatformat}}</strong>
|
||||
{% else %}
|
||||
<strong class="pull-right">{{vm.cpu|floatformat}}</strong>
|
||||
{% endif %}
|
||||
</p>
|
||||
<p>
|
||||
<span>{% trans "Memory" %}: </span>
|
||||
<strong class="pull-right">{{vm.memory}} GB</strong>
|
||||
</p>
|
||||
<p>
|
||||
<span>{% trans "Disk space" %}: </span>
|
||||
<strong class="pull-right">{{vm.disk_size}} GB</strong>
|
||||
</p>
|
||||
</div>
|
||||
<div class="col-sm-12">
|
||||
<hr class="thin-hr">
|
||||
</div>
|
||||
{% if vm.vat > 0 or vm.discount.amount > 0 %}
|
||||
<div class="col-sm-6">
|
||||
<div class="subtotal-price">
|
||||
{% if vm.vat > 0 %}
|
||||
<p>
|
||||
<strong>{% trans "Subtotal" %} </strong>
|
||||
<strong class="pull-right">{{vm.price|floatformat:2|intcomma}}
|
||||
CHF</strong>
|
||||
</p>
|
||||
<p>
|
||||
<small>{% trans "VAT" %} ({{ vm.vat_percent|floatformat:2|intcomma }}%)
|
||||
</small>
|
||||
<strong class="pull-right">{{vm.vat|floatformat:2|intcomma}} CHF</strong>
|
||||
</p>
|
||||
{% endif %}
|
||||
{% if vm.discount.amount > 0 %}
|
||||
<p class="text-primary">
|
||||
{%trans "Discount" as discount_name %}
|
||||
<strong>{{ vm.discount.name|default:discount_name }} </strong>
|
||||
<strong class="pull-right">- {{ vm.discount.amount }} CHF</strong>
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-12">
|
||||
<hr class="thin-hr">
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="col-sm-6">
|
||||
<p class="total-price">
|
||||
<strong>{% trans "Total" %} </strong>
|
||||
<strong class="pull-right">{% if vm.total_price %}{{vm.total_price|floatformat:2|intcomma}}{% else %}{{vm.price|floatformat:2|intcomma}}{% endif %}
|
||||
CHF</strong>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<p>
|
||||
<strong>{% trans "Product" %}:</strong>
|
||||
{{ product_name }}
|
||||
</p>
|
||||
<div class="row">
|
||||
<div class="col-sm-6">
|
||||
<p>
|
||||
<span>{% trans "Amount" %}: </span>
|
||||
<strong class="pull-right">{{total_in_chf|floatformat:2|intcomma}}
|
||||
CHF</strong>
|
||||
</p>
|
||||
{% if invoice.order.generic_payment_description %}
|
||||
<p>
|
||||
<span>{% trans "Description" %}: </span>
|
||||
<strong class="pull-right">{{invoice.order.generic_payment_description}}</strong>
|
||||
</p>
|
||||
{% endif %}
|
||||
{% if invoice.order.subscription_id %}
|
||||
<p>
|
||||
<span>{% trans "Recurring" %}: </span>
|
||||
<strong class="pull-right">{{invoice.order.created_at|date:'d'|ordinal}}
|
||||
{% trans "of every month" %}</strong>
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<hr class="thin-hr">
|
||||
</div>
|
||||
<div class="order_detail_footer">
|
||||
<strong>ungleich glarus ag</strong> Bahnhofstrasse 1, 8783
|
||||
Linthal, Switzerland<br>
|
||||
www.datacenterlight.ch | info@datacenterlight.ch | <small>
|
||||
CHE-156.970.649 MWST
|
||||
</small>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="text-center" style="margin-bottom: 50px;">
|
||||
<a class="btn btn-vm-back" href="{% url 'hosting:invoices' %}">{% trans "BACK TO LIST" %}</a>
|
||||
</div>
|
||||
|
||||
<script type="text/javascript">
|
||||
{% trans "Some problem encountered. Please try again later." as err_msg %}
|
||||
var create_vm_error_message = '{{err_msg|safe}}';
|
||||
</script>
|
||||
{%endblock%}
|
||||
|
||||
{% block js_extra %}
|
||||
{% if invoice.order %}
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/1.3.5/jspdf.min.js"></script>
|
||||
<script src="{% static 'hosting/js/html2canvas.min.js' %}"></script>
|
||||
<script src="{% static 'hosting/js/html2pdf.min.js' %}"></script>
|
||||
<script src="{% static 'hosting/js/order.js' %}"></script>
|
||||
{% endif %}
|
||||
{% endblock js_extra %}
|
61
hosting/templates/hosting/invoices.html
Normal file
61
hosting/templates/hosting/invoices.html
Normal file
|
@ -0,0 +1,61 @@
|
|||
{% extends "hosting/base_short.html" %}
|
||||
{% load staticfiles bootstrap3 humanize i18n custom_tags %}
|
||||
|
||||
{% block content %}
|
||||
<div class="dashboard-container">
|
||||
<div class="dashboard-container-head">
|
||||
<h3 class="dashboard-title-thin"><img src="{% static 'hosting/img/shopping-cart.svg' %}" class="un-icon" style="margin-top: -4px; width: 30px;"> {% trans "My Bills" %}</h3>
|
||||
{% if messages %}
|
||||
<div class="alert alert-warning">
|
||||
{% for message in messages %}
|
||||
<span>{{ message }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="dashboard-subtitle"></div>
|
||||
</div>
|
||||
|
||||
<table class="table table-switch">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "VM ID" %}</th>
|
||||
<th>{% trans "IP Address" %}</th>
|
||||
<th>{% trans "Period" %}</th>
|
||||
<th>{% trans "Amount" %}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for invoice in invoices %}
|
||||
<tr>
|
||||
<td class="xs-td-inline" data-header="{% trans 'VM ID' %}">{{ invoice.order.vm_id }}</td>
|
||||
<td class="xs-td-inline" data-header="{% trans 'IP Address' %}">{{ ips|get_value_from_dict:invoice.invoice_number|join:"<br/>" }}</td>
|
||||
{% with line_items|get_value_from_dict:invoice.invoice_number as line_items_to_show %}
|
||||
<td class="xs-td-inline" data-header="{% trans 'Period' %}">{{ line_items_to_show.0.period_start | date:'Y-m-d' }} — {{ line_items_to_show.0.period_end | date:'Y-m-d' }}</td>
|
||||
{% endwith %}
|
||||
<td class="xs-td-inline" data-header="{% trans 'Amount' %}">{{ invoice.total_in_chf|floatformat:2|intcomma }}</td>
|
||||
<td class="text-right last-td">
|
||||
<a class="btn btn-order-detail" href="{% url 'hosting:invoices' invoice.invoice_number %}">{% trans 'See Invoice' %}</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% if is_paginated %}
|
||||
<div class="pagination">
|
||||
<span class="page-links">
|
||||
{% if page_obj.has_previous %}
|
||||
<a href="{{request.path}}?page={{ page_obj.previous_page_number }}">{% trans "previous" %}</a>
|
||||
{% endif %}
|
||||
<span class="page-current">
|
||||
{% trans "Page" %} {{ page_obj.number }} {% trans "of" %} {{ page_obj.paginator.num_pages }}.
|
||||
</span>
|
||||
{% if page_obj.has_next %}
|
||||
<a href="{{request.path}}?page={{ page_obj.next_page_number }}">{% trans "next" %}</a>
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -8,7 +8,8 @@ from .views import (
|
|||
MarkAsReadNotificationView, PasswordResetView, PasswordResetConfirmView,
|
||||
HostingPricingView, CreateVirtualMachinesView, HostingBillListView,
|
||||
HostingBillDetailView, SSHKeyDeleteView, SSHKeyCreateView, SSHKeyListView,
|
||||
SSHKeyChoiceView, DashboardView, SettingsView, ResendActivationEmailView
|
||||
SSHKeyChoiceView, DashboardView, SettingsView, ResendActivationEmailView,
|
||||
InvoiceListView, InvoiceDetailView
|
||||
)
|
||||
|
||||
|
||||
|
@ -22,10 +23,13 @@ urlpatterns = [
|
|||
url(r'payment/?$', PaymentVMView.as_view(), name='payment'),
|
||||
url(r'settings/?$', SettingsView.as_view(), name='settings'),
|
||||
url(r'orders/?$', OrdersHostingListView.as_view(), name='orders'),
|
||||
url(r'invoices/?$', InvoiceListView.as_view(), name='invoices'),
|
||||
url(r'order-confirmation/?$', OrdersHostingDetailView.as_view(),
|
||||
name='order-confirmation'),
|
||||
url(r'orders/(?P<pk>\d+)/?$', OrdersHostingDetailView.as_view(),
|
||||
name='orders'),
|
||||
url(r'invoice/(?P<invoice_id>[-\w]+)/?$', InvoiceDetailView.as_view(),
|
||||
name='invoices'),
|
||||
url(r'bills/?$', HostingBillListView.as_view(), name='bills'),
|
||||
url(r'bills/(?P<pk>\d+)/?$', HostingBillDetailView.as_view(),
|
||||
name='bills'),
|
||||
|
|
189
hosting/views.py
189
hosting/views.py
|
@ -61,7 +61,7 @@ from .forms import (
|
|||
from .mixins import ProcessVMSelectionMixin, HostingContextMixin
|
||||
from .models import (
|
||||
HostingOrder, HostingBill, HostingPlan, UserHostingKey, VMDetail,
|
||||
GenericProduct
|
||||
GenericProduct, MonthlyHostingBill, HostingBillLineItem
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
@ -83,6 +83,19 @@ class DashboardView(LoginRequiredMixin, View):
|
|||
@method_decorator(decorators)
|
||||
def get(self, request, *args, **kwargs):
|
||||
context = self.get_context_data()
|
||||
context['has_invoices'] = False
|
||||
try:
|
||||
bills = []
|
||||
if hasattr(self.request.user, 'stripecustomer'):
|
||||
bills = MonthlyHostingBill.objects.filter(
|
||||
customer=self.request.user.stripecustomer
|
||||
)
|
||||
if len(bills) > 0:
|
||||
context['has_invoices'] = True
|
||||
except MonthlyHostingBill.DoesNotExist as dne:
|
||||
logger.error("{}'s monthly hosting bill not imported ?".format(
|
||||
self.request.user.email
|
||||
))
|
||||
return render(request, self.template_name, context)
|
||||
|
||||
|
||||
|
@ -1146,6 +1159,180 @@ class OrdersHostingListView(LoginRequiredMixin, ListView):
|
|||
return super(OrdersHostingListView, self).get(request, *args, **kwargs)
|
||||
|
||||
|
||||
class InvoiceListView(LoginRequiredMixin, ListView):
|
||||
template_name = "hosting/invoices.html"
|
||||
login_url = reverse_lazy('hosting:login')
|
||||
context_object_name = "invoices"
|
||||
paginate_by = 10
|
||||
ordering = '-created'
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super(InvoiceListView, self).get_context_data(**kwargs)
|
||||
if ('user_email' in self.request.GET
|
||||
and self.request.user.email == settings.ADMIN_EMAIL):
|
||||
user_email = self.request.GET['user_email']
|
||||
logger.debug(
|
||||
"user_email = {}".format(user_email)
|
||||
)
|
||||
try:
|
||||
cu = CustomUser.objects.get(email=user_email)
|
||||
except CustomUser.DoesNotExist as dne:
|
||||
logger.debug("User does not exist")
|
||||
cu = self.request.user
|
||||
mhbs = MonthlyHostingBill.objects.filter(customer__user=cu)
|
||||
else:
|
||||
mhbs = MonthlyHostingBill.objects.filter(
|
||||
customer__user=self.request.user
|
||||
)
|
||||
ips_dict = {}
|
||||
line_items_dict = {}
|
||||
for mhb in mhbs:
|
||||
try:
|
||||
vm_detail = VMDetail.objects.get(vm_id=mhb.order.vm_id)
|
||||
ips_dict[mhb.invoice_number] = [vm_detail.ipv6, vm_detail.ipv4]
|
||||
line_items_dict[mhb.invoice_number] = HostingBillLineItem.objects.filter(monthly_hosting_bill=mhb)
|
||||
except VMDetail.DoesNotExist as dne:
|
||||
ips_dict[mhb.invoice_number] = ['--']
|
||||
logger.debug("VMDetail for {} doesn't exist".format(
|
||||
mhb.order.vm_id
|
||||
))
|
||||
context['line_items'] = line_items_dict
|
||||
context['ips'] = ips_dict
|
||||
return context
|
||||
|
||||
def get_queryset(self):
|
||||
user = self.request.user
|
||||
if ('user_email' in self.request.GET
|
||||
and self.request.user.email == settings.ADMIN_EMAIL):
|
||||
user_email = self.request.GET['user_email']
|
||||
logger.debug(
|
||||
"user_email = {}".format(user_email)
|
||||
)
|
||||
try:
|
||||
cu = CustomUser.objects.get(email=user_email)
|
||||
except CustomUser.DoesNotExist as dne:
|
||||
logger.debug("User does not exist")
|
||||
cu = self.request.user
|
||||
self.queryset = MonthlyHostingBill.objects.filter(customer__user=cu)
|
||||
else:
|
||||
self.queryset = MonthlyHostingBill.objects.filter(
|
||||
customer__user=self.request.user
|
||||
)
|
||||
return super(InvoiceListView, self).get_queryset()
|
||||
|
||||
@method_decorator(decorators)
|
||||
def get(self, request, *args, **kwargs):
|
||||
return super(InvoiceListView, self).get(request, *args, **kwargs)
|
||||
|
||||
|
||||
class InvoiceDetailView(LoginRequiredMixin, DetailView):
|
||||
template_name = "hosting/invoice_detail.html"
|
||||
context_object_name = "invoice"
|
||||
login_url = reverse_lazy('hosting:login')
|
||||
permission_required = ['view_monthlyhostingbill']
|
||||
# model = MonthlyHostingBill
|
||||
|
||||
def get_object(self, queryset=None):
|
||||
invoice_id = self.kwargs.get('invoice_id')
|
||||
try:
|
||||
invoice_obj = MonthlyHostingBill.objects.get(
|
||||
invoice_number=invoice_id
|
||||
)
|
||||
logger.debug("Found MHB for id {invoice_id}".format(
|
||||
invoice_id=invoice_id
|
||||
))
|
||||
if self.request.user.has_perm(
|
||||
self.permission_required[0], invoice_obj
|
||||
) or self.request.user.email == settings.ADMIN_EMAIL:
|
||||
logger.debug("User has permission to invoice_obj")
|
||||
else:
|
||||
logger.error("User does not have permission to access")
|
||||
invoice_obj = None
|
||||
except MonthlyHostingBill.DoesNotExist as dne:
|
||||
logger.debug("MHB not found for id {invoice_id}".format(
|
||||
invoice_id=invoice_id
|
||||
))
|
||||
invoice_obj = None
|
||||
return invoice_obj
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
# Get context
|
||||
context = super(InvoiceDetailView, self).get_context_data(**kwargs)
|
||||
obj = self.get_object()
|
||||
|
||||
if obj is not None:
|
||||
vm_id = obj.get_vm_id()
|
||||
try:
|
||||
# Try to get vm details from database
|
||||
vm_detail = VMDetail.objects.get(vm_id=vm_id)
|
||||
context['vm'] = vm_detail.__dict__
|
||||
context['vm']['name'] = '{}-{}'.format(
|
||||
context['vm']['configuration'], context['vm']['vm_id'])
|
||||
price, vat, vat_percent, discount = get_vm_price_with_vat(
|
||||
cpu=context['vm']['cores'],
|
||||
ssd_size=context['vm']['disk_size'],
|
||||
memory=context['vm']['memory'],
|
||||
pricing_name=(obj.order.vm_pricing.name
|
||||
if obj.order.vm_pricing else 'default')
|
||||
)
|
||||
context['vm']['vat'] = vat
|
||||
context['vm']['price'] = price
|
||||
context['vm']['discount'] = discount
|
||||
context['vm']['vat_percent'] = vat_percent
|
||||
context['vm']['total_price'] = price + vat - discount['amount']
|
||||
except VMDetail.DoesNotExist:
|
||||
# fallback to get it from the infrastructure
|
||||
try:
|
||||
manager = OpenNebulaManager(
|
||||
email=self.request.email,
|
||||
password=self.request.password
|
||||
)
|
||||
vm = manager.get_vm(vm_id)
|
||||
context['vm'] = VirtualMachineSerializer(vm).data
|
||||
price, vat, vat_percent, discount = get_vm_price_with_vat(
|
||||
cpu=context['vm']['cores'],
|
||||
ssd_size=context['vm']['disk_size'],
|
||||
memory=context['vm']['memory'],
|
||||
pricing_name=(obj.order.vm_pricing.name
|
||||
if obj.order.vm_pricing else 'default')
|
||||
)
|
||||
context['vm']['vat'] = vat
|
||||
context['vm']['price'] = price
|
||||
context['vm']['discount'] = discount
|
||||
context['vm']['vat_percent'] = vat_percent
|
||||
context['vm']['total_price'] = (
|
||||
price + vat - discount['amount']
|
||||
)
|
||||
except WrongIdError:
|
||||
logger.error("WrongIdError while accessing "
|
||||
"invoice {}".format(obj.invoice_id))
|
||||
messages.error(
|
||||
self.request,
|
||||
_('The VM you are looking for is unavailable at the '
|
||||
'moment. Please contact Data Center Light support.')
|
||||
)
|
||||
self.kwargs['error'] = 'WrongIdError'
|
||||
context['error'] = 'WrongIdError'
|
||||
return context
|
||||
|
||||
# add context params from monthly hosting bill
|
||||
context['period_start'] = obj.get_period_start()
|
||||
context['period_end'] = obj.get_period_end()
|
||||
context['paid_at'] = obj.paid_at
|
||||
context['total_in_chf'] = obj.total_in_chf()
|
||||
context['invoice_number'] = obj.invoice_number
|
||||
context['discount_on_stripe'] = obj.discount_in_chf()
|
||||
return context
|
||||
else:
|
||||
raise Http404
|
||||
|
||||
@method_decorator(decorators)
|
||||
def get(self, request, *args, **kwargs):
|
||||
self.object = self.get_object()
|
||||
context = self.get_context_data(object=self.get_object())
|
||||
return self.render_to_response(context)
|
||||
|
||||
|
||||
class OrdersHostingDeleteView(LoginRequiredMixin, DeleteView):
|
||||
login_url = reverse_lazy('hosting:login')
|
||||
success_url = reverse_lazy('hosting:orders')
|
||||
|
|
|
@ -122,6 +122,49 @@ class StripeUtils(object):
|
|||
}
|
||||
return card_details
|
||||
|
||||
@handleStripeError
|
||||
def get_all_invoices(self, customer_id, created_gt):
|
||||
return_list = []
|
||||
has_more_invoices = True
|
||||
starting_after = False
|
||||
while has_more_invoices:
|
||||
if starting_after:
|
||||
invoices = stripe.Invoice.list(
|
||||
limit=10, customer=customer_id, created={'gt': created_gt},
|
||||
starting_after=starting_after
|
||||
)
|
||||
else:
|
||||
invoices = stripe.Invoice.list(
|
||||
limit=10, customer=customer_id, created={'gt': created_gt}
|
||||
)
|
||||
has_more_invoices = invoices.has_more
|
||||
for invoice in invoices.data:
|
||||
invoice_details = {
|
||||
'created': invoice.created,
|
||||
'receipt_number': invoice.receipt_number,
|
||||
'invoice_number': invoice.number,
|
||||
'paid_at': invoice.status_transitions.paid_at if invoice.paid else 0,
|
||||
'period_start': invoice.period_start,
|
||||
'period_end': invoice.period_end,
|
||||
'billing_reason': invoice.billing_reason,
|
||||
'discount': invoice.discount.coupon.amount_off if invoice.discount else 0,
|
||||
'total': invoice.total,
|
||||
# to see how many line items we have in this invoice and
|
||||
# then later check if we have more than 1
|
||||
'lines_data_count': len(invoice.lines.data) if invoice.lines.data is not None else 0,
|
||||
'invoice_id': invoice.id,
|
||||
'lines_meta_data_csv': ','.join(
|
||||
[line.metadata.VM_ID if hasattr(line.metadata, 'VM_ID') else '' for line in invoice.lines.data]
|
||||
),
|
||||
'subscription_ids_csv': ','.join(
|
||||
[line.id if line.type == 'subscription' else '' for line in invoice.lines.data]
|
||||
),
|
||||
'line_items': invoice.lines.data
|
||||
}
|
||||
starting_after = invoice.id
|
||||
return_list.append(invoice_details)
|
||||
return return_list
|
||||
|
||||
@handleStripeError
|
||||
def get_cards_details_from_token(self, token):
|
||||
stripe_token = stripe.Token.retrieve(token)
|
||||
|
|
Loading…
Reference in a new issue