diff --git a/billing/__init__.py b/billing/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/billing/admin.py b/billing/admin.py new file mode 100644 index 0000000..60be88e --- /dev/null +++ b/billing/admin.py @@ -0,0 +1,5 @@ +from django.contrib import admin + +from .models import Payment + +admin.site.register(Payment) diff --git a/billing/apps.py b/billing/apps.py new file mode 100644 index 0000000..7242aac --- /dev/null +++ b/billing/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class BillingConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "billing" diff --git a/billing/countries.py b/billing/countries.py new file mode 100644 index 0000000..4aae8b3 --- /dev/null +++ b/billing/countries.py @@ -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"), +] diff --git a/billing/migrations/0001_initial.py b/billing/migrations/0001_initial.py new file mode 100644 index 0000000..2dc2cc3 --- /dev/null +++ b/billing/migrations/0001_initial.py @@ -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" + ), + ), + ] diff --git a/billing/migrations/0002_alter_payment_billing_address_1_and_more.py b/billing/migrations/0002_alter_payment_billing_address_1_and_more.py new file mode 100644 index 0000000..4a445e4 --- /dev/null +++ b/billing/migrations/0002_alter_payment_billing_address_1_and_more.py @@ -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), + ), + ] diff --git a/billing/migrations/0003_delete_paymentdetail.py b/billing/migrations/0003_delete_paymentdetail.py new file mode 100644 index 0000000..0acf0dd --- /dev/null +++ b/billing/migrations/0003_delete_paymentdetail.py @@ -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", + ), + ] diff --git a/billing/migrations/0004_payment_billing_pay_paid_by_77f57c_idx.py b/billing/migrations/0004_payment_billing_pay_paid_by_77f57c_idx.py new file mode 100644 index 0000000..828c360 --- /dev/null +++ b/billing/migrations/0004_payment_billing_pay_paid_by_77f57c_idx.py @@ -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", + ), + ), + ] diff --git a/billing/migrations/__init__.py b/billing/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/billing/models.py b/billing/models.py new file mode 100644 index 0000000..5e9340f --- /dev/null +++ b/billing/models.py @@ -0,0 +1,127 @@ +# Copyright © 2022 Aravinth Manivannan +# +# 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 . +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", + ] + ), + ] diff --git a/billing/templates/billing/index.html b/billing/templates/billing/index.html new file mode 100644 index 0000000..a8aaaaa --- /dev/null +++ b/billing/templates/billing/index.html @@ -0,0 +1,24 @@ +{% extends 'dash/common/base.html' %} {% block dash %} +

{{ title }}

+
+ +

Breakdown

+
    +
  • Instance Name: {{payment.instance_name}}
  • +
  • Configuration: {{payment.instance_configuration_id.name}}
  • +
  • Invoice generated on: {{payment.date.month}}/{{payment.date.day}}/{{payment.date.year}}
  • +
  • Total Ammount: {{payment.total}} {{payment.currency|upper}}
  • + {% if payment.status == "confirmed" %} +
  • Paid on: {{payment.date}}
  • + {% endif %} +
+ {% if payment.status != "confirmed" %} +
+ {% csrf_token %} {{ form.as_p }} +
+ {% endif %} +
+{% endblock %} diff --git a/billing/templates/billing/invoices/common/invoices-list.html b/billing/templates/billing/invoices/common/invoices-list.html new file mode 100644 index 0000000..248d9b6 --- /dev/null +++ b/billing/templates/billing/invoices/common/invoices-list.html @@ -0,0 +1,7 @@ +{% for payment in payments %} +
  • + [{{ payment.date }}] {{payment.instance_name}}({{payment.instance_configuration_id.name}}) ------- {{ payment.total }} {{ payment.currency|upper}} +
  • +{% endfor %} diff --git a/billing/templates/billing/invoices/paid/index.html b/billing/templates/billing/invoices/paid/index.html new file mode 100644 index 0000000..add6c44 --- /dev/null +++ b/billing/templates/billing/invoices/paid/index.html @@ -0,0 +1,11 @@ +{% extends 'dash/common/base.html' %} {% block dash %} +

    {{ title }}

    + + +{% endblock %} diff --git a/billing/templates/billing/invoices/pending/index.html b/billing/templates/billing/invoices/pending/index.html new file mode 100644 index 0000000..b95acc5 --- /dev/null +++ b/billing/templates/billing/invoices/pending/index.html @@ -0,0 +1,11 @@ +{% extends 'dash/common/base.html' %} {% block dash %} +

    {{ title }}

    + + +{% endblock %} diff --git a/billing/tests.py b/billing/tests.py new file mode 100644 index 0000000..90d83fd --- /dev/null +++ b/billing/tests.py @@ -0,0 +1,115 @@ +# Copyright © 2022 Aravinth Manivannan +# +# 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 . +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) diff --git a/billing/urls.py b/billing/urls.py new file mode 100644 index 0000000..48d1579 --- /dev/null +++ b/billing/urls.py @@ -0,0 +1,50 @@ +# Copyright © 2022 Aravinth Manivannan +# +# 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 . +from django.urls import path + +from .views import ( + payment_details, + generate_invoice, + payment_success, + payment_failure, + pending_invoices, + paid_invoices, +) + +urlpatterns = [ + path( + "invoice/generate//", + generate_invoice, + name="billing.invoice.generate", + ), + path( + "invoice/payment//", + payment_details, + name="billing.invoice.details", + ), + path( + "invoice/payment//success/", + payment_success, + name="billing.invoice.success", + ), + path( + "invoice/payment//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"), +] diff --git a/billing/views.py b/billing/views.py new file mode 100644 index 0000000..fa87276 --- /dev/null +++ b/billing/views.py @@ -0,0 +1,130 @@ +# Copyright © 2022 Aravinth Manivannan +# +# 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 . +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")