diff --git a/CHANGELOG.md b/CHANGELOG.md index 90407a4..aff2a3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.0.34] - 2023-04-4 + +### Added + +- Subscriptions support [@AivGitHub](https://github.com/AivGitHub/). + ## [0.0.33] - 2023-03-28 ### Added diff --git a/api/v1/views.py b/api/v1/views.py index 96bcb9f..f20237d 100644 --- a/api/v1/views.py +++ b/api/v1/views.py @@ -1,31 +1,50 @@ from http import HTTPStatus -import json from django.conf import settings -from django.core.exceptions import PermissionDenied from django.http import HttpResponse from django.utils.decorators import method_decorator from django.views import View from django.views.decorators.csrf import csrf_exempt -import stripe +from payments.core import stripe +from payments.models import get_payment_instance, Subscription @method_decorator(csrf_exempt, name='dispatch') class StripeWebhook(View): def post(self, request, *args, **kwargs): try: - sig_header = request.headers['Stripe-Signature'] + signature = request.headers['Stripe-Signature'] except KeyError: return HttpResponse(status=HTTPStatus.FORBIDDEN) try: - event = stripe.Webhook.construct_event( - request.body, sig_header, settings.STRIPE_ENDPOINT_SECRET - ) + event = stripe.Webhook.construct_event(request.body, signature, settings.STRIPE_ENDPOINT_SECRET) except ValueError as e: - raise PermissionDenied() - except stripe.error.SignatureVerificationError as verification_err: return HttpResponse(status=HTTPStatus.FORBIDDEN) + except stripe.error.SignatureVerificationError: + return HttpResponse(status=HTTPStatus.FORBIDDEN) + + print(event.data.object.object) + if event.type == 'checkout.session.completed': + self.checkout_session_completed(event) + elif event.type == 'invoice.payment_succeeded': + self.invoice_payment_succeeded(event) + elif event.type == 'customer.subscription.updated': + self.customer_subscription_updated(event) return HttpResponse(status=HTTPStatus.OK) + + @staticmethod + def customer_subscription_updated(event: stripe.Event): + payment_instance = Subscription.objects.get(psp_id=event.data.object.id) + payment_instance.update_from_event(event) + + @staticmethod + def checkout_session_completed(event: stripe.Event): + payment_instance = get_payment_instance(event) + payment_instance.from_event(event, save=True) + + @staticmethod + def invoice_payment_succeeded(event: stripe.Event): + pass diff --git a/payments/admin.py b/payments/admin.py index f39dca7..5987ebf 100644 --- a/payments/admin.py +++ b/payments/admin.py @@ -1,6 +1,16 @@ from django.contrib import admin -from payments.models import Subscription +from payments.models import Price, Product, Subscription + + +@admin.register(Product) +class ProductAdmin(admin.ModelAdmin): + pass + + +@admin.register(Price) +class PriceAdmin(admin.ModelAdmin): + pass @admin.register(Subscription) diff --git a/payments/migrations/0003_delete_subscription.py b/payments/migrations/0003_delete_subscription.py new file mode 100644 index 0000000..4dca092 --- /dev/null +++ b/payments/migrations/0003_delete_subscription.py @@ -0,0 +1,16 @@ +# Generated by Django 4.1.3 on 2023-04-04 19:48 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('payments', '0002_subscription_psp_id_subscription_psp_reference'), + ] + + operations = [ + migrations.DeleteModel( + name='Subscription', + ), + ] diff --git a/payments/migrations/0004_initial.py b/payments/migrations/0004_initial.py new file mode 100644 index 0000000..3344aab --- /dev/null +++ b/payments/migrations/0004_initial.py @@ -0,0 +1,76 @@ +# Generated by Django 4.1.3 on 2023-04-04 21:31 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('payments', '0003_delete_subscription'), + ] + + operations = [ + migrations.CreateModel( + name='Product', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('active', models.BooleanField(default=False, verbose_name='Active')), + ('description', models.CharField(blank=True, max_length=256, null=True, verbose_name='Description')), + ('psp_id', models.CharField(max_length=64, verbose_name='PSP ID')), + ('metadata', models.JSONField(blank=True, null=True, verbose_name='Metadata')), + ('name', models.CharField(blank=True, max_length=256, null=True, verbose_name='Description')), + ('object_name', models.CharField(max_length=16, verbose_name='Object name')), + ('product_type', models.CharField(max_length=32, verbose_name='Product type')), + ], + ), + migrations.CreateModel( + name='Subscription', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('psp_id', models.CharField(max_length=64, verbose_name='PSP ID')), + ('active', models.BooleanField(default=False, verbose_name='Active')), + ('current_period_end', models.DateTimeField(default=django.utils.timezone.now, verbose_name='Current period end')), + ('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='subscriptions', to='payments.product')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='subscriptions', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='Price', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('active', models.BooleanField(default=False, verbose_name='Active')), + ('billing_scheme', models.CharField(max_length=128, verbose_name='Billing scheme')), + ('currency', models.CharField(max_length=8, verbose_name='Currency')), + ('psp_id', models.CharField(max_length=64, verbose_name='PSP ID')), + ('metadata', models.JSONField(blank=True, null=True, verbose_name='Metadata')), + ('object_name', models.CharField(max_length=16, verbose_name='Object name')), + ('recurring', models.JSONField(blank=True, null=True, verbose_name='Recurring')), + ('payment_type', models.CharField(max_length=32, verbose_name='Payment type')), + ('unit_amount', models.PositiveIntegerField(verbose_name='Unit amount')), + ('unit_amount_decimal', models.CharField(max_length=32, verbose_name='Unit amount decimal')), + ('product', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='prices', to='payments.product')), + ], + ), + migrations.CreateModel( + name='Payment', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('psp_id', models.CharField(max_length=64, verbose_name='PSP ID')), + ('active', models.BooleanField(default=False, verbose_name='Active')), + ('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='payments', to='payments.product')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='payments', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/payments/models.py b/payments/models.py index 48ccb61..f676c98 100644 --- a/payments/models.py +++ b/payments/models.py @@ -1,94 +1,338 @@ +import datetime + +from django.conf import settings from django.db import models +from django.urls import reverse +from django.utils import timezone from django.utils.translation import gettext_lazy as _ +from accounts.models import User +from base.utils import generate_jwt_signature +from payments.core import stripe -class ProductBase(models.Model): - title = models.CharField( - _('Title'), - max_length=128, + +class Product(models.Model): + active = models.BooleanField( + _('Active'), + default=False + ) + description = models.CharField( + _('Description'), + max_length=256, + null=True, + blank=True + ) + psp_id = models.CharField( + _('PSP ID'), + max_length=64, editable=True, null=False, blank=False ) - description = models.TextField( - _('Description'), - editable=True, + metadata = models.JSONField( + _('Metadata'), null=True, blank=True ) - price = models.DecimalField( - _('Price'), - max_digits=16, - decimal_places=2, - editable=True, + name = models.CharField( + _('Description'), + max_length=256, null=True, blank=True ) - sku = models.CharField( - _('SKU'), + object_name = models.CharField( + _('Object name'), max_length=16, editable=True, - null=True, - blank=True + null=False, + blank=False ) - currency = models.CharField( - _('Currency'), - max_length=8, + product_type = models.CharField( + _('Product type'), + max_length=32, editable=True, null=False, blank=False ) + + @staticmethod + def populate(products: list = None): + """Populates products from PSP. + + TODO: Optimise method, for now it's not critical. + Method is not optimised and inserts all records 1 by 1. + But this method won't be called often. + + Args: + products (list, optional): list of products. + """ + if products is None: + # TODO: change limit=100 to starting_after=product_id + products = stripe.Product.list(limit=100).data + + # TODO: Change to bulk update + for product in products: + product_obj, created = Product.objects.get_or_create(psp_id=product.id) + + product_obj.active = product.active + product_obj.description = product.description + product_obj.metadata = product.metadata.to_dict() + product_obj.name = product.name + product_obj.object_name = product.object + product_obj.product_type = product.type + product_obj.product_type = product.type + + product_obj.save() + + def is_available(self): + return self.active + + +class Price(models.Model): active = models.BooleanField( _('Active'), default=False ) - psp_reference = models.CharField( + billing_scheme = models.CharField( + _('Billing scheme'), + max_length=128, + editable=True, + null=False, + blank=False + ) + currency = models.CharField( + _('Currency'), + max_length=8, + editable=True, + null=False, + blank=False + ) + psp_id = models.CharField( _('PSP ID'), - max_length=32, - editable=False, + max_length=64, + editable=True, + null=False, + blank=False + ) + metadata = models.JSONField( + _('Metadata'), null=True, blank=True ) - psp_id = models.CharField( - _('PSP ID'), + object_name = models.CharField( + _('Object name'), + max_length=16, + editable=True, + null=False, + blank=False + ) + recurring = models.JSONField( + _('Recurring'), + null=True, + blank=True + ) + payment_type = models.CharField( + _('Payment type'), + max_length=32, + editable=True, + null=False, + blank=False + ) + unit_amount = models.PositiveIntegerField( + _('Unit amount'), + null=False, + blank=False + ) + unit_amount_decimal = models.CharField( + _('Unit amount decimal'), max_length=32, - editable=False, + null=False, + blank=False + ) + product = models.ForeignKey( + Product, + on_delete=models.CASCADE, + related_name='prices', null=True, blank=True ) + @staticmethod + def populate(): + """Populates prices from PSP. + + TODO: Optimise method, for now it's not critical. + Method is not optimised and inserts all records 1 by 1. + But this method won't be called often. + """ + # TODO: change limit=100 to starting_after=price_id + price_list = stripe.Price.list(expand=['data.product'], limit=100) + # We need to populate ``payments.models.Product`` first as ``payments.models.Price`` has + # foreign key on ``payments.models.Product``. + Product.populate(products=[price.product for price in price_list.data]) + + for price in price_list.data: + try: + price_obj = Price.objects.get(psp_id=price.id) + except Price.DoesNotExist: + price_obj = Price(psp_id=price.id) + + price_obj.active = price.active + price_obj.billing_scheme = price.billing_scheme + price_obj.currency = price.currency.upper() + price_obj.psp_id = price.id + price_obj.metadata = price.metadata.to_dict() + price_obj.object_name = price.object + price_obj.recurring = price.recurring or {} + price_obj.payment_type = price.type + price_obj.unit_amount = price.unit_amount + price_obj.unit_amount_decimal = price.unit_amount_decimal + price_obj.product = Product.objects.get(psp_id=price.product.id) + + price_obj.save() + + def get_mode(self): + if self.recurring: + return 'subscription' + return 'payment' + + def get_payment_session(self, user): + if settings.PAYMENT_HOST.startswith(('http://', 'https://')): + payment_host = settings.PAYMENT_HOST + else: + scheme = 'https' + if settings.DEBUG: + scheme = 'http' + payment_host = '%s://%s' % (scheme, settings.PAYMENT_HOST) + + metadata = dict( + user_id=user.id, + product_id=self.product.id, + price_id=self.id + ) + metadata.update(self.metadata) + hash_ = generate_jwt_signature({'price_id': self.id}, expiration_time=86400) + session_kwargs = dict( + line_items=[ + { + 'price': self.psp_id, + 'quantity': 1, + }, + ], + mode=self.get_mode(), + success_url='%s%s?hash=%s' % (payment_host, reverse('payments:payment_success'), hash_), + cancel_url='%s%s' % (payment_host, reverse('payments:product_prices', kwargs={'pk': self.product.id})), + metadata=metadata + ) + if user.psp_id is not None: + session_kwargs.update(dict(customer=user.psp_id)) + + return stripe.checkout.Session.create(**session_kwargs) + + +class PaymentBase(models.Model): + user = models.ForeignKey( + 'accounts.User', + on_delete=models.CASCADE, + related_name='payments', + null=False, + blank=False + ) + product = models.ForeignKey( + Product, + on_delete=models.CASCADE, + related_name='payments', + null=False, + blank=False + ) + psp_id = models.CharField( + _('PSP ID'), + max_length=64, + editable=True, + null=False, + blank=False + ) + active = models.BooleanField( + _('Active'), + default=False + ) + class Meta: abstract = True - ordering = ['id'] - def get_internal_info(self, user): + def from_event(self, event: stripe.Event, save=False): + """Creates an object from stripe event. + + Args: + event (stripe.Event): Stripe event. + save (bool, optional): Save object before return. + """ raise NotImplementedError() + def update_from_event(self, event: stripe.Event): + raise NotImplementedError() -class Subscription(ProductBase): - DEFAULT_MAX_STORAGE_SIZE: int = 2147483648 # 2 * 2 ^ 30 = 2 GB - DEFAULT_MAX_FILE_SIZE: int = 209715200 # Maximum file size200 * 2 ^ 20 = 200 MB - max_file_size = models.PositiveBigIntegerField( - _('Maximum file size'), - default=DEFAULT_MAX_FILE_SIZE, - null=True, - blank=True +class Payment(PaymentBase): + @classmethod + def from_event(cls, event: stripe.Event, save=False): + pass + + def update_from_event(self, event: stripe.Event): + pass + + +class Subscription(PaymentBase): + user = models.ForeignKey( + 'accounts.User', + on_delete=models.CASCADE, + related_name='subscriptions', + null=False, + blank=False ) - storage_size = models.PositiveBigIntegerField( - _('Storage size'), - default=DEFAULT_MAX_STORAGE_SIZE, - null=True, - blank=True + product = models.ForeignKey( + Product, + on_delete=models.CASCADE, + related_name='subscriptions', + null=False, + blank=False ) + current_period_end = models.DateTimeField( + _('Current period end'), + default=datetime.datetime.now + ) + + @classmethod + def from_event(cls, event: stripe.Event, save=False): + product: Product = Product.objects.get(id=event.data.object.metadata.product_id) + user: User = User.objects.get(id=event.data.object.metadata.user_id) + user.configure_from_event(event) - def get_internal_info(self, user): - msg: str = _('Product is available!') - is_available: bool = True - is_current: bool = False + subscription = cls( + user=user, + product=product, + psp_id=event.data.object.subscription + ) + if save: + subscription.save() - return { - 'is_available': is_available, - 'message': msg, - 'is_current': is_current, - } + return subscription + + def update_from_event(self, event: stripe.Event): + self.current_period_end = datetime.datetime.utcfromtimestamp(event.data.object.current_period_end) + self.active = True + + self.save(update_fields=['active', 'current_period_end']) + + +MODE_TO_PAYMENT_INSTANCE = { + 'subscription': Subscription, + 'payment': Payment, +} + + +def get_payment_instance(event: stripe.Event): + try: + return MODE_TO_PAYMENT_INSTANCE[event.data.object.mode] + except KeyError: + raise NotImplementedError() diff --git a/payments/templates/payments/includes/product.html b/payments/templates/payments/includes/product.html index 51b3fb6..8d4f500 100644 --- a/payments/templates/payments/includes/product.html +++ b/payments/templates/payments/includes/product.html @@ -6,16 +6,22 @@
- {{ product.title }}{% if product_internal_info.is_current %} | ({% translate "Current" %}){% endif %} + {{ product.name }}

-
- {{ product.description }} -
+ {% if product.description %} +
+ {{ product.description }} +
+ {% endif %}
    -
  • {% translate "Max upload file size" %}: {{ product.max_file_size | filesizeformat }}
  • -
  • {% translate "Storage size" %}: {{ product.storage_size | filesizeformat }}
  • + {% if product.metadata.max_file_size %} +
  • {% translate "Max upload file size" %}: {{ product.metadata.max_file_size | filesizeformat }}
  • + {% endif %} + {% if product.metadata.storage_size %} +
  • {% translate "Storage size" %}: {{ product.metadata.storage_size | filesizeformat }}
  • + {% endif %} {% for point in product.item_points.all %}
  • {{ point.description }}
  • {% endfor %} @@ -23,17 +29,24 @@
diff --git a/payments/templates/payments/product_detail.html b/payments/templates/payments/product_detail.html new file mode 100644 index 0000000..51706bb --- /dev/null +++ b/payments/templates/payments/product_detail.html @@ -0,0 +1,37 @@ +{% extends "accounts/base_site.html" %} + +{% load extras i18n static %} + +{% block page_title %} + {% translate "Products" %} +{% endblock %} + +{% block container %} +
+
+
+
+
+
+
+ {% translate "Product" %} {{ product.name}} +
+
+ {% if product %} +
+ {% for price in product.prices.all %} + {% if price.active %} + {% include "payments/includes/product.html" with product=product %} + {% endif %} + {% endfor %} +
+ {% else %} +

{% translate "No products found." %}

+ {% endif %} +
+
+
+
+
+
+{% endblock %} diff --git a/payments/templates/payments/products.html b/payments/templates/payments/products.html index d7a48b0..f510c7f 100644 --- a/payments/templates/payments/products.html +++ b/payments/templates/payments/products.html @@ -20,7 +20,9 @@
{% if products %}
{% for product in products %} - {% include "payments/includes/product.html" with product=product %} + {% if product.active and product.prices.all %} + {% include "payments/includes/product.html" with product=product price=product.prices.all.0 %} + {% endif %} {% endfor %}
{% else %} diff --git a/payments/templates/payments/success.html b/payments/templates/payments/success.html new file mode 100644 index 0000000..386d031 --- /dev/null +++ b/payments/templates/payments/success.html @@ -0,0 +1,27 @@ +{% extends "accounts/base_site.html" %} + +{% load extras i18n static %} + +{% block page_title %} + {% translate "Products" %} +{% endblock %} + +{% block container %} +
+
+
+
+
+
+
+ {% translate "Congratulations!" %} +
+
+

{% translate "You've bought" %} {{ price.product.name }} {% translate "product!" %}

+
+
+
+
+
+
+{% endblock %} diff --git a/payments/templatetags/payments_extras.py b/payments/templatetags/payments_extras.py index c38882d..d670a44 100644 --- a/payments/templatetags/payments_extras.py +++ b/payments/templatetags/payments_extras.py @@ -1,11 +1,16 @@ from django import template from accounts.models import User -from payments.models import ProductBase +from payments.models import Price, Product register = template.Library() @register.simple_tag -def get_product_internal_info(product: ProductBase, user: User): - return product.get_internal_info(user) +def get_product_internal_info(product: Product, user: User): + return {} + + +@register.simple_tag +def get_hprice(price: Price): + return price.unit_amount / 100 diff --git a/payments/urls.py b/payments/urls.py index f41b2f4..9c1f06a 100644 --- a/payments/urls.py +++ b/payments/urls.py @@ -2,7 +2,9 @@ from payments.views import ( PaymentCallbackView, + PaymentSuccessView, ProcessPaymentView, + ProductView, ProductsView, ) @@ -14,4 +16,6 @@ path('/process/', ProcessPaymentView.as_view(), name='process_payment'), path('products/', ProductsView.as_view(), name='products'), path('callbacks//', PaymentCallbackView.as_view()), + path('products//', ProductView.as_view(), name='product_prices'), + path('success/', PaymentSuccessView.as_view(), name='payment_success'), ] diff --git a/payments/views.py b/payments/views.py index 014b7ad..6b7e74b 100644 --- a/payments/views.py +++ b/payments/views.py @@ -6,7 +6,9 @@ from django.utils.translation import gettext as _ from django.views import View -from payments.models import Subscription +from base.exceptions import FatalSignatureError, SignatureExpiredError +from base.utils import decode_jwt_signature +from payments.models import Price, Product class ProductsView(View): @@ -14,7 +16,7 @@ class ProductsView(View): def get(self, request, *args, **kwargs): # TODO: Don't do like this - products = Subscription.objects.all() + products = Product.objects.all() return render( request, @@ -35,11 +37,11 @@ def post(self, request, *args, **kwargs): raise PermissionDenied() try: - product = Subscription.objects.get(id=product_id) - except Subscription.DoesNotExist: + product = Product.objects.get(id=product_id) + except Product.DoesNotExist: raise PermissionDenied() - raise PermissionDenied() + return redirect('payments:product_prices', pk=product.id) class ProcessPaymentView(LoginRequiredMixin, View): @@ -57,3 +59,61 @@ class PaymentCallbackView(LoginRequiredMixin, View): def get(self, request, *args, **kwargs): raise PermissionDenied() + + +class ProductView(View): + template_name = 'payments/product_detail.html' + + def get(self, request, *args, **kwargs): + try: + product_id = kwargs['pk'] + except KeyError: + raise PermissionDenied() + try: + product = Product.objects.get(id=product_id) + except Product.DoesNotExist: + raise PermissionDenied() + + return render( + request, + template_name=self.template_name, + context={ + 'product': product, + } + ) + + def post(self, request, *args, **kwargs): + price_id = request.POST.get('price_id') + if price_id is None: + raise PermissionDenied() + + try: + price = Price.objects.get(id=price_id) + except Price.DoesNotExist: + raise PermissionDenied() + + payment_session = price.get_payment_session(request.user) + return redirect(payment_session.url) + + +class PaymentSuccessView(View): + template_name = 'payments/success.html' + + def get(self, request, *args, **kwargs): + try: + hash_ = request.GET['hash'] + except KeyError: + raise PermissionDenied() + try: + payload = decode_jwt_signature(hash_) + except (FatalSignatureError, SignatureExpiredError): + raise PermissionDenied() + + price = Price.objects.get(id=payload.get('price_id')) + return render( + request, + template_name=self.template_name, + context={ + 'price': price, + } + )