forked from Hostea/dashboard
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 dashboardwip-site
parent
760c0e90af
commit
d470033429
|
@ -0,0 +1,5 @@
|
||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from .models import Payment
|
||||||
|
|
||||||
|
admin.site.register(Payment)
|
|
@ -0,0 +1,6 @@
|
||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class BillingConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "billing"
|
|
@ -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"),
|
||||||
|
]
|
|
@ -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"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
|
@ -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),
|
||||||
|
),
|
||||||
|
]
|
|
@ -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",
|
||||||
|
),
|
||||||
|
]
|
|
@ -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",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
|
@ -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",
|
||||||
|
]
|
||||||
|
),
|
||||||
|
]
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -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)
|
|
@ -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"),
|
||||||
|
]
|
|
@ -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")
|
Loading…
Reference in New Issue