From d470033429386a1c2b8f5f040c59b82f9311fe70 Mon Sep 17 00:00:00 2001 From: realaravinth Date: Wed, 22 Jun 2022 00:37:03 +0530 Subject: [PATCH] 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 --- billing/__init__.py | 0 billing/admin.py | 5 + billing/apps.py | 6 + billing/countries.py | 92 ++++++ billing/migrations/0001_initial.py | 283 ++++++++++++++++++ ...lter_payment_billing_address_1_and_more.py | 48 +++ .../migrations/0003_delete_paymentdetail.py | 16 + ..._payment_billing_pay_paid_by_77f57c_idx.py | 20 ++ billing/migrations/__init__.py | 0 billing/models.py | 127 ++++++++ billing/templates/billing/index.html | 24 ++ .../invoices/common/invoices-list.html | 7 + .../billing/invoices/paid/index.html | 11 + .../billing/invoices/pending/index.html | 11 + billing/tests.py | 115 +++++++ billing/urls.py | 50 ++++ billing/views.py | 130 ++++++++ 17 files changed, 945 insertions(+) create mode 100644 billing/__init__.py create mode 100644 billing/admin.py create mode 100644 billing/apps.py create mode 100644 billing/countries.py create mode 100644 billing/migrations/0001_initial.py create mode 100644 billing/migrations/0002_alter_payment_billing_address_1_and_more.py create mode 100644 billing/migrations/0003_delete_paymentdetail.py create mode 100644 billing/migrations/0004_payment_billing_pay_paid_by_77f57c_idx.py create mode 100644 billing/migrations/__init__.py create mode 100644 billing/models.py create mode 100644 billing/templates/billing/index.html create mode 100644 billing/templates/billing/invoices/common/invoices-list.html create mode 100644 billing/templates/billing/invoices/paid/index.html create mode 100644 billing/templates/billing/invoices/pending/index.html create mode 100644 billing/tests.py create mode 100644 billing/urls.py create mode 100644 billing/views.py 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 }}

    + +
      + {% if payments|length %} + {% include "billing/invoices/common/invoices-list.html" %} + {% else %} +

      You haven't made any payments yet!

      + {% endif %} +
    +{% 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 }}

    + +
      + {% if payments|length %} + {% include "billing/invoices/common/invoices-list.html" %} + {% else %} +

      No pending invoices!

      + {% endif %} +
    +{% 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")