feat: implement billing with stripe

SUMMARY
    PAYMENT WORKFLOW
	1. User is redirected after a new instance is created into a view
	   that generates invoice. There are checks in place to ensure
	   invoices are not generated twice for the same VM. There is also a
	   check in place to generate invoices for time periods that are
	   already paid for
	2. User is redirected to payment form
	3. Stripe takes over
	4. If payment is successful, user is redirected to success page
	5. If payment failed, user is redirected to failure page

    PENDING INVOICES
	The user can see pending invoices on their dashboard

    PAYMENT RECEIPTS
	The user can see payment receipts on their dashboard
wip-payments
Aravinth Manivannan 2022-06-22 00:37:03 +05:30
parent 760c0e90af
commit d470033429
Signed by: realaravinth
GPG Key ID: AD9F0F08E855ED88
17 changed files with 945 additions and 0 deletions

0
billing/__init__.py Normal file
View File

5
billing/admin.py Normal file
View File

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

6
billing/apps.py Normal file
View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class BillingConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "billing"

92
billing/countries.py Normal file
View File

@ -0,0 +1,92 @@
AUSTRALIA = "AU"
AUSTRIA = "AT"
BELGIUM = "BE"
BRAZIL = "BR"
BULGARIA = "BG"
CANADA = "CA"
CROATIA = "HR"
CYPRUS = "CY"
CZECH_REPUBLIC = "CZ"
DENMARK = "DK"
ESTONIA = "EE"
FINLAND = "FI"
FRANCE = "FR"
GERMANY = "DE"
GIBRALTAR = "GI"
GREECE = "GR"
HONG_KONG = "HK"
HUNGARY = "HU"
INDIA = "IN"
IRELAND = "IE"
ITALY = "IT"
JAPAN = "JP"
LATVIA = "LV"
LIECHTENSTEIN = "LI"
LITHUANIA = "LT"
LUXEMBOURG = "LU"
MALAYSIA = "MY"
MALTA = "MT"
MEXICO = "MX"
NETHERLANDS = "NL"
NEW_ZEALAND = "NZ"
NORWAY = "NO"
POLAND = "PL"
PORTUGAL = "PT"
ROMANIA = "RO"
SINGAPORE = "SG"
SLOVAKIA = "SK"
SLOVENIA = "SI"
SPAIN = "ES"
SWEDEN = "SE"
SWITZERLAND = "CH"
UNITED_ARAB_EMIRATES = "AE"
UNITED_KINGDOM = "GB"
UNITED_STATES = "US"
COUNTRIES = [
(AUSTRALIA, "Australia"),
(AUSTRIA, "Austria"),
(BELGIUM, "Belgium"),
(BRAZIL, "Brazil"),
(BULGARIA, "Bulgaria"),
(CANADA, "Canada"),
(CROATIA, "Croatia"),
(CYPRUS, "Cyprus"),
(CZECH_REPUBLIC, "Czech Republic"),
(DENMARK, "Denmark"),
(ESTONIA, "Estonia"),
(FINLAND, "Finland"),
(FRANCE, "France"),
(GERMANY, "Germany"),
(GIBRALTAR, "Gibraltar"),
(GREECE, "Greece"),
(HONG_KONG, "Hong Kong"),
(HUNGARY, "Hungary"),
(INDIA, "India"),
(IRELAND, "Ireland"),
(ITALY, "Italy"),
(JAPAN, "Japan"),
(LATVIA, "Latvia"),
(LIECHTENSTEIN, "Liechtenstein"),
(LITHUANIA, "Lithuania"),
(LUXEMBOURG, "Luxembourg"),
(MALAYSIA, "Malaysia"),
(MALTA, "Malta"),
(MEXICO, "Mexico"),
(NETHERLANDS, "Netherlands"),
(NEW_ZEALAND, "New Zealand"),
(NORWAY, "Norway"),
(POLAND, "Poland"),
(PORTUGAL, "Portugal"),
(ROMANIA, "Romania"),
(SINGAPORE, "Singapore"),
(SLOVAKIA, "Slovakia"),
(SLOVENIA, "Slovenia"),
(SPAIN, "Spain"),
(SWEDEN, "Sweden"),
(SWITZERLAND, "Switzerland"),
(UNITED_ARAB_EMIRATES, "United Arab Emirates"),
(UNITED_KINGDOM, "United Kingdom"),
(UNITED_STATES, "United States"),
]

View File

@ -0,0 +1,283 @@
# Generated by Django 4.0.3 on 2022-06-21 15:18
import accounts.utils
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import phonenumber_field.modelfields
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("dash", "0006_auto_20220619_0800"),
]
operations = [
migrations.CreateModel(
name="PaymentDetail",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("first_name", models.CharField(max_length=2000)),
("last_name", models.CharField(max_length=2000)),
("address_1", models.CharField(max_length=2000)),
("address_2", models.CharField(max_length=2000)),
("city", models.CharField(max_length=200)),
("postcode", models.CharField(max_length=10)),
(
"country_code",
models.CharField(
choices=[
("AU", "Australia"),
("AT", "Austria"),
("BE", "Belgium"),
("BR", "Brazil"),
("BG", "Bulgaria"),
("CA", "Canada"),
("HR", "Croatia"),
("CY", "Cyprus"),
("CZ", "Czech Republic"),
("DK", "Denmark"),
("EE", "Estonia"),
("FI", "Finland"),
("FR", "France"),
("DE", "Germany"),
("GI", "Gibraltar"),
("GR", "Greece"),
("HK", "Hong Kong"),
("HU", "Hungary"),
("IN", "India"),
("IE", "Ireland"),
("IT", "Italy"),
("JP", "Japan"),
("LV", "Latvia"),
("LI", "Liechtenstein"),
("LT", "Lithuania"),
("LU", "Luxembourg"),
("MY", "Malaysia"),
("MT", "Malta"),
("MX", "Mexico"),
("NL", "Netherlands"),
("NZ", "New Zealand"),
("NO", "Norway"),
("PL", "Poland"),
("PT", "Portugal"),
("RO", "Romania"),
("SG", "Singapore"),
("SK", "Slovakia"),
("SI", "Slovenia"),
("ES", "Spain"),
("SE", "Sweden"),
("CH", "Switzerland"),
("AE", "United Arab Emirates"),
("GB", "United Kingdom"),
("US", "United States"),
],
max_length=2,
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
],
),
migrations.CreateModel(
name="Payment",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("variant", models.CharField(max_length=255)),
(
"status",
models.CharField(
choices=[
("waiting", "Waiting for confirmation"),
("preauth", "Pre-authorized"),
("confirmed", "Confirmed"),
("rejected", "Rejected"),
("refunded", "Refunded"),
("error", "Error"),
("input", "Input"),
],
default="waiting",
max_length=10,
),
),
(
"fraud_status",
models.CharField(
choices=[
("unknown", "Unknown"),
("accept", "Passed"),
("reject", "Rejected"),
("review", "Review"),
],
default="unknown",
max_length=10,
verbose_name="fraud check",
),
),
("fraud_message", models.TextField(blank=True, default="")),
("created", models.DateTimeField(auto_now_add=True)),
("modified", models.DateTimeField(auto_now=True)),
("transaction_id", models.CharField(blank=True, max_length=255)),
("currency", models.CharField(max_length=10)),
(
"total",
models.DecimalField(decimal_places=2, default="0.0", max_digits=9),
),
(
"delivery",
models.DecimalField(decimal_places=2, default="0.0", max_digits=9),
),
(
"tax",
models.DecimalField(decimal_places=2, default="0.0", max_digits=9),
),
("description", models.TextField(blank=True, default="")),
("billing_country_area", models.CharField(blank=True, max_length=256)),
("billing_email", models.EmailField(blank=True, max_length=254)),
(
"billing_phone",
phonenumber_field.modelfields.PhoneNumberField(
blank=True, max_length=128, region=None
),
),
(
"customer_ip_address",
models.GenericIPAddressField(blank=True, null=True),
),
("extra_data", models.TextField(blank=True, default="")),
("message", models.TextField(blank=True, default="")),
("token", models.CharField(blank=True, default="", max_length=36)),
(
"captured_amount",
models.DecimalField(decimal_places=2, default="0.0", max_digits=9),
),
(
"instance_name",
models.CharField(
max_length=200,
verbose_name="Name of this Instance. Also Serves as subdomain",
),
),
("billing_first_name", models.CharField(max_length=2000)),
("billing_last_name", models.CharField(max_length=2000)),
("billing_address_1", models.CharField(max_length=2000)),
("billing_address_2", models.CharField(max_length=2000)),
("billing_city", models.CharField(max_length=200)),
("billing_postcode", models.CharField(max_length=10)),
(
"billing_country_code",
models.CharField(
choices=[
("AU", "Australia"),
("AT", "Austria"),
("BE", "Belgium"),
("BR", "Brazil"),
("BG", "Bulgaria"),
("CA", "Canada"),
("HR", "Croatia"),
("CY", "Cyprus"),
("CZ", "Czech Republic"),
("DK", "Denmark"),
("EE", "Estonia"),
("FI", "Finland"),
("FR", "France"),
("DE", "Germany"),
("GI", "Gibraltar"),
("GR", "Greece"),
("HK", "Hong Kong"),
("HU", "Hungary"),
("IN", "India"),
("IE", "Ireland"),
("IT", "Italy"),
("JP", "Japan"),
("LV", "Latvia"),
("LI", "Liechtenstein"),
("LT", "Lithuania"),
("LU", "Luxembourg"),
("MY", "Malaysia"),
("MT", "Malta"),
("MX", "Mexico"),
("NL", "Netherlands"),
("NZ", "New Zealand"),
("NO", "Norway"),
("PL", "Poland"),
("PT", "Portugal"),
("RO", "Romania"),
("SG", "Singapore"),
("SK", "Slovakia"),
("SI", "Slovenia"),
("ES", "Spain"),
("SE", "Sweden"),
("CH", "Switzerland"),
("AE", "United Arab Emirates"),
("GB", "United Kingdom"),
("US", "United States"),
],
max_length=2,
),
),
(
"public_ref",
models.CharField(
default=accounts.utils.gen_secret,
editable=False,
max_length=32,
unique=True,
verbose_name="Public referent to the payment record",
),
),
("date", models.DateTimeField(auto_now_add=True)),
(
"instance_configuration_id",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
to="dash.instanceconfiguration",
),
),
(
"paid_by",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
],
),
migrations.AddIndex(
model_name="payment",
index=models.Index(
fields=["public_ref"], name="billing_pay_public__ee30a3_idx"
),
),
migrations.AddIndex(
model_name="payment",
index=models.Index(
fields=["paid_by"], name="billing_pay_paid_by_419604_idx"
),
),
]

View File

@ -0,0 +1,48 @@
# Generated by Django 4.0.3 on 2022-06-21 15:26
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("billing", "0001_initial"),
]
operations = [
migrations.AlterField(
model_name="payment",
name="billing_address_1",
field=models.CharField(blank=True, max_length=256),
),
migrations.AlterField(
model_name="payment",
name="billing_address_2",
field=models.CharField(blank=True, max_length=256),
),
migrations.AlterField(
model_name="payment",
name="billing_city",
field=models.CharField(blank=True, max_length=256),
),
migrations.AlterField(
model_name="payment",
name="billing_country_code",
field=models.CharField(blank=True, max_length=2),
),
migrations.AlterField(
model_name="payment",
name="billing_first_name",
field=models.CharField(blank=True, max_length=256),
),
migrations.AlterField(
model_name="payment",
name="billing_last_name",
field=models.CharField(blank=True, max_length=256),
),
migrations.AlterField(
model_name="payment",
name="billing_postcode",
field=models.CharField(blank=True, max_length=256),
),
]

View File

@ -0,0 +1,16 @@
# Generated by Django 4.0.3 on 2022-06-21 15:45
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("billing", "0002_alter_payment_billing_address_1_and_more"),
]
operations = [
migrations.DeleteModel(
name="PaymentDetail",
),
]

View File

@ -0,0 +1,20 @@
# Generated by Django 4.0.3 on 2022-06-21 16:06
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("billing", "0003_delete_paymentdetail"),
]
operations = [
migrations.AddIndex(
model_name="payment",
index=models.Index(
fields=["paid_by", "instance_name"],
name="billing_pay_paid_by_77f57c_idx",
),
),
]

View File

127
billing/models.py Normal file
View File

@ -0,0 +1,127 @@
# Copyright © 2022 Aravinth Manivannan <realaravinth@batsense.net>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from decimal import Decimal
from typing import Iterable
from urllib.parse import urlparse, urlunparse
from django.db import models
from django.urls import reverse
from django.contrib.auth.models import User
from django.conf import settings
from payments import PurchasedItem
from payments.models import BasePayment
from dash.models import InstanceConfiguration, Instance
from accounts.utils import gen_secret
class PaymentModelManager(models.Manager):
def create(self, variant: str, instance: Instance):
owner = instance.owned_by
instance_config = instance.configuration_id
description = f"{instance_config.name} VM {instance.name} rent"
return super().create(
variant=variant,
description=description,
paid_by=owner,
total=instance_config.rent,
delivery=Decimal(0),
tax=Decimal(0),
instance_name=instance.name,
instance_configuration_id=instance_config,
currency="eur",
)
class Payment(BasePayment):
paid_by = models.ForeignKey(User, on_delete=models.CASCADE)
instance_configuration_id = models.ForeignKey(
InstanceConfiguration, on_delete=models.PROTECT
)
instance_name = models.CharField(
"Name of this Instance. Also Serves as subdomain",
max_length=200,
)
public_ref = models.CharField(
"Public referent to the payment record",
unique=True,
max_length=32,
default=gen_secret,
editable=False,
)
date = models.DateTimeField(auto_now_add=True, blank=True)
objects = PaymentModelManager()
def get_failure_url(self) -> str:
url = urlparse(settings.PAYMENT_HOST)
return urlunparse(
(
url.scheme,
url.netloc,
reverse("billing.invoice.fail", args=(self.public_ref,)),
"",
"",
"",
)
)
def get_success_url(self) -> str:
url = urlparse(settings.PAYMENT_HOST)
return urlunparse(
(
url.scheme,
url.netloc,
reverse("billing.invoice.success", args=(self.public_ref,)),
"",
"",
"",
)
)
def get_purchased_items(self) -> Iterable[PurchasedItem]:
# Return items that will be included in this payment.
yield PurchasedItem(
name=self.instance_name,
sku=self.instance_configuration_id.name,
quantity=1,
price=self.price,
currency="EUR",
)
def __str__(self):
return f"[{self.date}][{self.paid_by}][{self.instance_name}] {self.total}"
class Meta:
indexes = [
models.Index(
fields=[
"public_ref",
]
),
models.Index(
fields=[
"paid_by",
"instance_name",
]
),
models.Index(
fields=[
"paid_by",
]
),
]

View File

@ -0,0 +1,24 @@
{% extends 'dash/common/base.html' %} {% block dash %}
<h1>{{ title }}</h1>
<section>
<noscript>
Please enable JavaScript to process payments. We rely on Stripe for
processing payments, which requires JavaScript.
</noscript>
<h2>Breakdown</h2>
<ul class="list-instance__container">
<li class="list-instance__item"><strong>Instance Name:</strong> {{payment.instance_name}}</li>
<li class="list-instance__item"><strong>Configuration:</strong> {{payment.instance_configuration_id.name}}</li>
<li class="list-instance__item"><strong>Invoice generated on:</strong> {{payment.date.month}}/{{payment.date.day}}/{{payment.date.year}}</li>
<li class="list-instance__item"><strong>Total Ammount</strong>: {{payment.total}} {{payment.currency|upper}}</li>
{% if payment.status == "confirmed" %}
<li class="list-instance__item"><strong>Paid on</strong>: {{payment.date}}</li>
{% endif %}
</ul>
{% if payment.status != "confirmed" %}
<form class="dash__form" action="{{ form.action }}" method="{{ form.method }}">
{% csrf_token %} {{ form.as_p }}
</form>
{% endif %}
</section>
{% endblock %}

View File

@ -0,0 +1,7 @@
{% for payment in payments %}
<li class="list-instance__item">
<a
href="{% url 'billing.invoice.details' payment_public_id=payment.public_ref %}"
class="href">[{{ payment.date }}] {{payment.instance_name}}({{payment.instance_configuration_id.name}}) ------- {{ payment.total }} {{ payment.currency|upper}}</a>
</li>
{% endfor %}

View File

@ -0,0 +1,11 @@
{% extends 'dash/common/base.html' %} {% block dash %}
<h2>{{ title }}</h2>
<ul class="list-instance__container">
{% if payments|length %}
{% include "billing/invoices/common/invoices-list.html" %}
{% else %}
<p>You haven't made any payments yet!</p>
{% endif %}
</ul>
{% endblock %}

View File

@ -0,0 +1,11 @@
{% extends 'dash/common/base.html' %} {% block dash %}
<h2>{{ title }}</h2>
<ul class="list-instance__container">
{% if payments|length %}
{% include "billing/invoices/common/invoices-list.html" %}
{% else %}
<p>No pending invoices!</p>
{% endif %}
</ul>
{% endblock %}

115
billing/tests.py Normal file
View File

@ -0,0 +1,115 @@
# Copyright © 2022 Aravinth Manivannan <realaravinth@batsense.net>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import time
from io import StringIO
from django.contrib.auth import get_user_model
from django.core.management import call_command
from django.urls import reverse
from django.test import TestCase, Client, override_settings
from django.utils.http import urlencode
from django.contrib.auth import authenticate
from django.conf import settings
from payments import get_payment_model, RedirectNeeded, PaymentStatus
from accounts.tests import register_util, login_util
from dash.tests import create_configurations, create_instance_util
from .models import Payment
class BillingTest(TestCase):
"""
Tests billing system
"""
def setUp(self):
self.username = "billing_user"
register_util(t=self, username=self.username)
create_configurations(t=self)
def test_payments(self):
c = Client()
login_util(self, c, "accounts.home")
instance_name = "test_create_instance_renders"
create_instance_util(
t=self, c=c, instance_name=instance_name, config=self.instance_config[0]
)
payment_uri = reverse("billing.invoice.generate", args=(instance_name,))
# generate invoice
resp = c.get(payment_uri)
self.assertEqual(resp.status_code, 302)
invoice_uri = resp.headers["Location"]
self.assertEqual("invoice/payment/" in invoice_uri, True)
# try to generate duplicate invoice, but should get redirected to previous invoice
resp = c.get(payment_uri)
self.assertEqual(resp.status_code, 302)
self.assertEqual(invoice_uri == resp.headers["Location"], True)
# check if invoice details page is displaying the invoice
# if payment is yet to be made:
# template will show payment button
# else:
# template will show payment date
resp = c.get(invoice_uri)
self.assertEqual(str.encode(instance_name) in resp.content, True)
self.assertEqual(
str.encode(str(self.instance_config[0].rent)) in resp.content, True
)
self.assertEqual(str.encode("Paid on") in resp.content, False)
# check if the unpaid invoice is displayed in the pending invoice view
resp = c.get(reverse("billing.invoice.pending"))
self.assertEqual(str.encode(invoice_uri) in resp.content, True)
# simulate payment. There's probably a better way to do this
payment = get_payment_model().objects.get(paid_by=self.user)
payment.status = PaymentStatus.CONFIRMED
payment.save()
# check if paid invoice is listed in paid invoice list view
resp = c.get(reverse("billing.invoice.paid"))
self.assertEqual(str.encode(invoice_uri) in resp.content, True)
# check if the paid invoice is displayed in the pending invoice view, should not be displayed
resp = c.get(reverse("billing.invoice.pending"))
self.assertEqual(str.encode(invoice_uri) in resp.content, False)
# check if the invoice details view is rendering paid invoice version
resp = c.get(invoice_uri)
self.assertEqual(str.encode(instance_name) in resp.content, True)
self.assertEqual(
str.encode(str(self.instance_config[0].rent)) in resp.content, True
)
self.assertEqual(str.encode("Paid on") in resp.content, True)
# try to generate an invoice for the second time on the same VM
# shouldn't be possible since payment is already made for the duration
resp = c.get(payment_uri)
self.assertEqual(resp.status_code, 400)
## payment success page; no real functionality but user is redirected here
# by stripe if payment is successful
resp = c.get(reverse("billing.invoice.success", args=(payment.public_ref,)))
self.assertEqual(b"success" in resp.content, True)
## payment failure page; no real functionality but user is redirected here
# by stripe if payment is successful
resp = c.get(reverse("billing.invoice.fail", args=(payment.public_ref,)))
self.assertEqual(b"failed" in resp.content, True)

50
billing/urls.py Normal file
View File

@ -0,0 +1,50 @@
# Copyright © 2022 Aravinth Manivannan <realaravinth@batsense.net>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from django.urls import path
from .views import (
payment_details,
generate_invoice,
payment_success,
payment_failure,
pending_invoices,
paid_invoices,
)
urlpatterns = [
path(
"invoice/generate/<str:instance_name>/",
generate_invoice,
name="billing.invoice.generate",
),
path(
"invoice/payment/<str:payment_public_id>/",
payment_details,
name="billing.invoice.details",
),
path(
"invoice/payment/<str:payment_public_id>/success/",
payment_success,
name="billing.invoice.success",
),
path(
"invoice/payment/<str:payment_public_id>/failure/",
payment_failure,
name="billing.invoice.fail",
),
path("invoice/pending/", pending_invoices, name="billing.invoice.pending"),
path("invoice/paid/", paid_invoices, name="billing.invoice.paid"),
path("", pending_invoices, name="billing.home"),
]

130
billing/views.py Normal file
View File

@ -0,0 +1,130 @@
# Copyright © 2022 Aravinth Manivannan <realaravinth@batsense.net>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from datetime import datetime, timedelta, timezone
from django.shortcuts import get_object_or_404, redirect, render
from django.template.response import TemplateResponse
from django.contrib.auth.decorators import login_required
from django.http import HttpResponse
from django.urls import reverse
from payments import get_payment_model, RedirectNeeded, PaymentStatus
from dash.models import Instance
from django.db.models import Q
def default_ctx(title: str, username: str):
"""
Default context for all dashboard
"""
return {
"title": title,
"username": username,
}
@login_required
def pending_invoices(request):
PAGE_TITLE = "Pending Invoices"
user = request.user
Payment = get_payment_model()
payments = Payment.objects.filter(~Q(status=PaymentStatus.CONFIRMED), paid_by=user)
ctx = default_ctx(title=PAGE_TITLE, username=user.username)
ctx["payments"] = payments
return render(request, "billing/invoices/pending/index.html", context=ctx)
@login_required
def paid_invoices(request):
PAGE_TITLE = "Payment Receipts"
user = request.user
Payment = get_payment_model()
payments = Payment.objects.filter(status=PaymentStatus.CONFIRMED, paid_by=user)
ctx = default_ctx(title=PAGE_TITLE, username=user.username)
ctx["payments"] = payments
return render(request, "billing/invoices/paid/index.html", context=ctx)
@login_required
def generate_invoice(request, instance_name: str):
instance = get_object_or_404(Instance, name=instance_name, owned_by=request.user)
Payment = get_payment_model()
now = datetime.now(tz=timezone.utc)
delta = now - timedelta(seconds=(60 * 60 * 24 * 30)) # one month
payment = None
for p in Payment.objects.filter(date__gt=(delta)):
if p.status == PaymentStatus.CONFIRMED:
return HttpResponse("BAD REQUEST: Already paid", status=400)
elif any([p.status == PaymentStatus.INPUT, p.status == PaymentStatus.WAITING]):
if payment is None:
payment = p
else:
print(f"Duplicate payment {p}, deleting in favor of {payment}")
p.delete()
if payment is None:
print("Payment not found, generating new payment")
payment = Payment.objects.create(
variant="stripe", # this is the variant from PAYMENT_VARIANTS
instance=instance,
)
return redirect(reverse("billing.invoice.details", args=(payment.public_ref,)))
@login_required
def payment_details(request, payment_public_id):
payment = get_object_or_404(
get_payment_model(), public_ref=payment_public_id, paid_by=request.user
)
try:
form = payment.get_form(data=request.POST or None)
except RedirectNeeded as redirect_to:
return redirect(str(redirect_to))
PAGE_TITLE = f'Invoice for VM "{payment.instance_name}"'
ctx = default_ctx(title=PAGE_TITLE, username=request.user.username)
ctx["form"] = form
ctx["payment"] = payment
return render(request, "billing/index.html", ctx)
@login_required
def payment_success(request, payment_public_id):
PAGE_TITLE = "Payment Success"
ctx = default_ctx(title=PAGE_TITLE, username=request.user.username)
payment = get_object_or_404(
get_payment_model(), public_ref=payment_public_id, paid_by=request.user
)
return HttpResponse(
f"{payment.description} worth {payment.total}{payment.currency} paid via {payment.variant} is success"
)
@login_required
def payment_failure(request, payment_public_id):
PAGE_TITLE = "Payment failure"
payment = get_object_or_404(
get_payment_model(), public_ref=payment_public_id, paid_by=request.user
)
ctx = default_ctx(title=PAGE_TITLE, username=request.user.username)
return HttpResponse(f"{payment} failed")