diff --git a/CHANGELOG.md b/CHANGELOG.md index aa09649..eb3faec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,15 @@ and this project adheres to [Semantic Versioning](http://semver.org/). See for sample https://raw.githubusercontent.com/favoloso/conventional-changelog-emoji/master/CHANGELOG.md --> +## [1.5.0] - 2023-11-DD +### ✨ Feature +- Make **trial period** customizable (#98) +- Set **auto_Sync to True** by default (#99) +- Make product **price as Float** (#100) +- Implement **subscription** model from stripe for Saas application (#101) +### 🔒 Security +- Migrate to **Django 4.2+** (#71) + ## [1.4.0] - 2023-06-19 ### 🛠 Improvements - Add boolean to **store if private key has been saved** and verified (#91) diff --git a/docs/conf.py b/docs/conf.py index fac0954..8f4e4c9 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -65,9 +65,9 @@ # built documents. # # The short X.Y version. -version = '1.4' +version = '1.5' # The full version, including alpha/beta/rc tags. -release = '1.4.0' +release = '1.5.0' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. diff --git a/docs/deployment.md b/docs/deployment.md index d560585..8d5d0f3 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -51,4 +51,15 @@ EMAIL_HOST_PASSWORD = YOUR_EMAIL_PASSWORD MAIL_USE_TLS = false EMAIL_PORT = 25 DEBUG = false +``` + +## Saas + +Deploying as Saas require to connect with Strip and add dedicated env var: + + +```shell +STRIPE_PUBLIC_KEY = YOUR_PUBLIC_KEY +STRIPE_SECRET_KEY = YOUR_SECRET_KEY +STRIPE_PRODUCT = YOUR_PRODUCT_ID_FOR_SUBSCRIPTION ``` \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 7475b81..1c14936 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,18 +1,25 @@ +alabaster==0.7.13 +argh==0.28.1 asgiref==3.6.0 +astroid==2.15.3 +Babel==2.12.1 +backports.ssl-match-hostname==3.7.0.1 boto3==1.26.114 botocore==1.29.114 certifi==2022.12.7 cffi==1.15.1 chardet==5.1.0 charset-normalizer==3.1.0 +colorama==0.4.6 colorclass==2.2.2 coreapi==2.3.3 coreschema==0.0.4 cryptography==40.0.2 defusedxml==0.7.1 +dill==0.3.6 dj-database-url==1.3.0 dj-rest-auth==3.0.0 -Django==4.1.9 +Django==4.2.7 django-allauth==0.54.0 django-appconf==1.0.5 django-colorfield==0.8.0 @@ -28,28 +35,45 @@ django-storages==1.13.2 djangorestframework==3.14.0 djangorestframework-bulk==0.2.1 docopt==0.6.2 -docutils==0.18 +docutils==0.17.1 drf-writable-nested==0.7.0 drf-yasg==1.21.5 gunicorn==20.1.0 idna==3.4 +imagesize==1.4.1 +importlib-metadata==6.4.1 inflection==0.5.1 +isort==5.12.0 itypes==1.2.0 Jinja2==3.1.2 jmespath==1.0.1 +lazy-object-proxy==1.9.0 libsass==0.22.0 +livereload==2.6.3 +markdown-it-py==2.2.0 markdown2==2.4.8 MarkupSafe==2.1.2 +mccabe==0.7.0 +mdit-py-plugins==0.3.5 +mdurl==0.1.2 +myst-parser==1.0.0 oauthlib==3.2.2 openapi-codec==1.3.2 packaging==23.1 +pathtools==0.1.2 Pillow==9.5.0 pip-review==1.3.0 pip-upgrader==1.4.15 +platformdirs==3.2.0 psycopg2==2.9.6 psycopg2-binary==2.9.6 pycparser==2.21 +Pygments==2.15.0 PyJWT==2.6.0 +pylint==2.17.2 +pylint-django==2.5.3 +pylint-plugin-utils==0.7 +PyMySQL==1.0.3 pyparsing==3.0.9 python-dateutil==2.8.2 python-dotenv==1.0.0 @@ -66,10 +90,28 @@ s3transfer==0.6.0 sentry-sdk==1.19.1 simplejson==3.19.1 six==1.16.0 -sqlparse==0.4.4 +snowballstemmer==2.2.0 +Sphinx==5.3.0 +sphinx-autobuild==2021.3.14 +sphinx-reload==0.2.0 +sphinx-rtd-theme==1.1.1 +sphinxcontrib-applehelp==1.0.2 +sphinxcontrib-devhelp==1.0.2 +sphinxcontrib-htmlhelp==2.0.0 +sphinxcontrib-jquery==4.1 +sphinxcontrib-jsmath==1.0.1 +sphinxcontrib-qthelp==1.0.3 +sphinxcontrib-serializinghtml==1.1.5 +sqlparse==0.4.3 stripe==5.4.0 terminaltables==3.1.10 +tomli==2.0.1 +tomlkit==0.11.7 +tornado==6.2 typing_extensions==4.5.0 uritemplate==4.1.1 urllib3==1.26.15 -whitenoise==6.4.0 \ No newline at end of file +watchdog==3.0.0 +whitenoise==6.4.0 +wrapt==1.15.0 +zipp==3.15.0 diff --git a/seven23/api/saas/views.py b/seven23/api/saas/views.py index b94221b..9603d36 100644 --- a/seven23/api/saas/views.py +++ b/seven23/api/saas/views.py @@ -5,11 +5,14 @@ import json import os import markdown2 +from django.utils import timezone +from datetime import datetime import urllib.parse from django.http import HttpResponse from django.views.decorators.csrf import csrf_exempt from rest_framework import status +from django.contrib.auth.models import User from django.db import models from rest_framework.decorators import api_view @@ -20,101 +23,82 @@ from seven23 import settings from seven23.models.terms.models import TermsAndConditions -from seven23.models.saas.models import Charge, Product, Coupon -from seven23.models.saas.serializers import ChargeSerializer +from seven23.models.saas.models import Price, StripeSubscription stripe.api_key = settings.STRIPE_SECRET_KEY @api_view(['GET']) -def ApiCoupon(request, product_id, coupon_code): - res = {} - - product = get_object_or_404(Product, pk=product_id) - coupon = get_object_or_404(Coupon, code=coupon_code) - - if not coupon.is_active(): - return Response(status=status.HTTP_404_NOT_FOUND) +def StripeGenerateSession(request): - res['coupon_id'] = coupon.id - res['percent_off'] = coupon.percent_off - res['price'] = product.price - (product.price * coupon.percent_off / 100) + if hasattr(request.user, 'stripe') and request.user.profile.valid_until > timezone.now(): - j = json.dumps(res, separators=(',', ':')) - return HttpResponse(j, content_type='application/json') + if not request.GET.get("return_url"): + return Response(status=status.HTTP_400_BAD_REQUEST) -@api_view(['GET']) -def StripeGenerateSession(request): + session = stripe.billing_portal.Session.create( + customer=request.user.profile.stripe_customer_id, + return_url=request.GET.get("return_url"), + ) - product = None - coupon = None + j = json.dumps({ + 'session_id': session + }, separators=(',', ':')) + return HttpResponse(j, content_type='application/json') - if request.GET.get("product_id") and request.GET.get("success_url") and request.GET.get("cancel_url"): - product = Product.objects.get(pk=request.GET.get("product_id")) else: - return Response(status=status.HTTP_400_BAD_REQUEST) - - if not product.stripe_product_id: - return Response(status=status.HTTP_400_BAD_REQUEST) + price = None - try: - coupon = Coupon.objects.get(code=request.GET.get('coupon_code')) - except: - pass - - if coupon and coupon.is_active() and product.apply_coupon(request.GET.get('coupon_code')) == 0: - charge = Charge.objects.create( - user=request.user, - product=product, - coupon=coupon, - paiment_method='COUPON', - status='SUCCESS' + if request.GET.get("price_id") and request.GET.get("success_url") and request.GET.get("cancel_url"): + price = Price.objects.get(pk=request.GET.get("price_id")) + else: + return Response(status=status.HTTP_400_BAD_REQUEST) + + if not price.stripe_price_id: + return Response(status=status.HTTP_400_BAD_REQUEST) + + stripe_customer_id = None + + if hasattr(request.user, 'stripe'): + # if user model has stripe foreign object + stripe_customer_id = request.user.profile.stripe_customer_id + + trial_end_date = None + + if request.user.profile.valid_until > timezone.now(): + trial_end_date = request.user.profile.valid_until + # Make sure trial_end_date is at least two days in the future + # (Stripe requirement) + if trial_end_date < timezone.now() + timezone.timedelta(days=2, hours=1): + trial_end_date = timezone.now() + timezone.timedelta(days=2, hours=1) + + session = stripe.checkout.Session.create( + customer=stripe_customer_id, + customer_email=request.user.email if not stripe_customer_id else None, + payment_method_types=['card'], + client_reference_id=request.user.pk, + subscription_data= { + 'trial_end': trial_end_date, + }, + line_items=[{ + 'price': price.stripe_price_id, + 'quantity': 1, + }], + mode='subscription', + # success_url='https://example.com/success?session_id={CHECKOUT_SESSION_ID}', + # cancel_url='https://example.com/cancel', + success_url=urllib.parse.urljoin(request.GET.get("success_url"), 'success'), + cancel_url=request.GET.get("cancel_url"), ) - j = json.dumps(ChargeSerializer(charge).data, separators=(',', ':')) - return HttpResponse(j, content_type='application/json') - price = stripe.Price.create( - product=product.stripe_product_id, - unit_amount=int(product.apply_coupon(request.GET.get('coupon_code')) * 100), - active=True, - currency='eur' - ) - - stripe_customer_id = request.user.profile.stripe_customer_id - session = stripe.checkout.Session.create( - customer=request.user.profile.stripe_customer_id, - customer_email=request.user.email if not stripe_customer_id else None, - payment_method_types=['card'], - line_items=[{ - 'price': price.id, - 'quantity': 1, - }], - mode='payment', - # success_url='https://example.com/success?session_id={CHECKOUT_SESSION_ID}', - # cancel_url='https://example.com/cancel', - success_url=urllib.parse.urljoin(request.GET.get("success_url"), 'success'), - cancel_url=request.GET.get("cancel_url"), - ) - - Charge.objects.create( - user=request.user, - product=product, - coupon=coupon, - paiment_method='STRIPE', - reference_id=session.id, - stripe_session_id=session.id, - status='PENDING' - ) - - j = json.dumps({ - 'session_id': session - }, separators=(',', ':')) - return HttpResponse(j, content_type='application/json') + j = json.dumps({ + 'session_id': session + }, separators=(',', ':')) + return HttpResponse(j, content_type='application/json') @csrf_exempt @api_view(['POST']) def StripeWebhook(request): payload = request.body - print(payload) try: event = stripe.Event.construct_from( json.loads(payload), stripe.api_key @@ -122,23 +106,65 @@ def StripeWebhook(request): except ValueError as e: # Invalid payload return HttpResponse(status=400) + # Handle the event - if event.type == 'checkout.session.completed': - checkout = event.data.object # contains a stripe.PaymentIntent + if event.type == 'customer.subscription.created' or event.type == 'customer.subscription.updated': + + subscription = event.data.object + + stripeCustomer, created = StripeSubscription.objects.get_or_create( + subscription_id=subscription.id + ) - charge = Charge.objects.get(reference_id=checkout.id) - if charge: - charge.status = 'SUCCESS' - charge.save() + if subscription.trial_end: + stripeCustomer.trial_end = timezone.make_aware(datetime.utcfromtimestamp(subscription.trial_end), timezone=timezone.utc) + else: + stripeCustomer.trial_end = None - charge.user.profile.stripe_customer_id = checkout.customer - charge.user.profile.save() - return Response(status=status.HTTP_200_OK) + if subscription.current_period_end: + stripeCustomer.current_period_end = timezone.make_aware(datetime.utcfromtimestamp(subscription.current_period_end), timezone=timezone.utc) else: - return HttpResponse(status=400) - print(checkout) - print('checkout was completed!') + stripeCustomer.current_period_end = None + + if subscription.cancel_at: + stripeCustomer.cancel_at = timezone.make_aware(datetime.utcfromtimestamp(subscription.cancel_at), timezone=timezone.utc) + else: + stripeCustomer.cancel_at = None + + stripeCustomer.status = subscription.status + stripeCustomer.price = get_object_or_404(Price, stripe_price_id=subscription.plan.id) + stripeCustomer.is_active = True + stripeCustomer.save() + + return Response(status=status.HTTP_200_OK) + elif event.type == 'customer.subscription.deleted': + subscription = event.data.object + try: + StripeSubscription.objects.filter(subscription_id=subscription.id).delete() + except: + pass + return Response(status=status.HTTP_200_OK) + elif event.type == 'checkout.session.completed': + # We create a StripeCustomer object to store user's data from Stripe + checkout = event.data.object + user = get_object_or_404(User, pk=checkout.client_reference_id) + + # checkout.client_reference_id is 1, 2, 4 ... user.pk + # checkout.subscription is sub_1OHNiZILP1DzcVdZmb3bqdLr + # checkout.customer is cus_P5Yqm6ZYR1CHFc + + subscription, created = StripeSubscription.objects.get_or_create( + subscription_id=checkout.subscription, + ) + subscription.user = user + subscription.save() + + user.profile.stripe_customer_id = checkout.customer + user.profile.save() + + return Response(status=status.HTTP_200_OK) else: + print(event) # Unexpected event type - return HttpResponse(status=400) + return HttpResponse(status=200) return Response(status=status.HTTP_200_OK) \ No newline at end of file diff --git a/seven23/api/urls.py b/seven23/api/urls.py index 792fd7b..1cd7fee 100644 --- a/seven23/api/urls.py +++ b/seven23/api/urls.py @@ -9,7 +9,7 @@ from seven23.api.views import api_init from seven23.api.users.views import revoke_token, email, delete_user -from seven23.api.saas.views import ApiCoupon, StripeGenerateSession, StripeWebhook +from seven23.api.saas.views import StripeGenerateSession, StripeWebhook from seven23 import settings @@ -46,5 +46,4 @@ urlpatterns = urlpatterns + [ path(r'v1/stripe/session', StripeGenerateSession, name='api.stripe.session'), path(r'v1/stripe/webhook', StripeWebhook, name='api.stripe.webhook'), - path(r'v1/coupon//', ApiCoupon, name='api.coupon'), ] \ No newline at end of file diff --git a/seven23/api/users/tests_users.py b/seven23/api/users/tests_users.py index 0b365ed..3b12e17 100644 --- a/seven23/api/users/tests_users.py +++ b/seven23/api/users/tests_users.py @@ -50,5 +50,5 @@ def test_registration_new_user(self): self.assertTrue('auto_sync' in data['profile']) self.assertTrue('key_verified' in data['profile']) self.assertTrue('social_networks' in data['profile']) - self.assertFalse(data['profile']['auto_sync']) + self.assertTrue(data['profile']['auto_sync']) self.assertFalse(data['profile']['key_verified']) \ No newline at end of file diff --git a/seven23/api/views.py b/seven23/api/views.py index 8b4286f..5e5b71a 100644 --- a/seven23/api/views.py +++ b/seven23/api/views.py @@ -13,8 +13,8 @@ from seven23 import settings from seven23.models.terms.models import TermsAndConditions -from seven23.models.saas.serializers import ProductSerializer -from seven23.models.saas.models import Product +from seven23.models.saas.serializers import PriceSerializer, StripeSubscriptionSerializer +from seven23.models.saas.models import Price from allauth.account.models import EmailAddress @@ -31,9 +31,15 @@ def api_init(request): result['saas'] = settings.SAAS result['allow_account_creation'] = settings.ALLOW_ACCOUNT_CREATION result['contact'] = settings.CONTACT_EMAIL + result['subscription'] = False + + if hasattr(request.user, 'stripe'): + result['subscription'] = StripeSubscriptionSerializer(request.user.stripe).data + result['subscription_price'] = PriceSerializer(request.user.stripe.price).data if result['saas']: - result['products'] = ProductSerializer(list(Product.objects.all()), many=True).data + result['stripe_product'] = settings.STRIPE_PRODUCT + result['stripe_prices'] = PriceSerializer(list(Price.objects.all()), many=True).data result['trial_period'] = settings.TRIAL_PERIOD result['stripe_key'] = settings.STRIPE_PUBLIC_KEY diff --git a/seven23/models/profile/migrations/0014_alter_profile_auto_sync.py b/seven23/models/profile/migrations/0014_alter_profile_auto_sync.py new file mode 100644 index 0000000..bceb88b --- /dev/null +++ b/seven23/models/profile/migrations/0014_alter_profile_auto_sync.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.7 on 2023-11-27 04:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('profile', '0013_profile_key_verified'), + ] + + operations = [ + migrations.AlterField( + model_name='profile', + name='auto_sync', + field=models.BooleanField(default=True, verbose_name='Auto sync in app'), + ), + ] diff --git a/seven23/models/profile/migrations/0015_remove_profile_stripe_customer_id.py b/seven23/models/profile/migrations/0015_remove_profile_stripe_customer_id.py new file mode 100644 index 0000000..457af40 --- /dev/null +++ b/seven23/models/profile/migrations/0015_remove_profile_stripe_customer_id.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.7 on 2023-11-28 04:51 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('profile', '0014_alter_profile_auto_sync'), + ] + + operations = [ + migrations.RemoveField( + model_name='profile', + name='stripe_customer_id', + ), + ] diff --git a/seven23/models/profile/migrations/0016_profile_stripe_customer_id.py b/seven23/models/profile/migrations/0016_profile_stripe_customer_id.py new file mode 100644 index 0000000..47b568e --- /dev/null +++ b/seven23/models/profile/migrations/0016_profile_stripe_customer_id.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.7 on 2023-12-03 10:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('profile', '0015_remove_profile_stripe_customer_id'), + ] + + operations = [ + migrations.AddField( + model_name='profile', + name='stripe_customer_id', + field=models.CharField(blank=True, max_length=255), + ), + ] diff --git a/seven23/models/profile/models.py b/seven23/models/profile/models.py index 327a61f..7482986 100644 --- a/seven23/models/profile/models.py +++ b/seven23/models/profile/models.py @@ -31,23 +31,19 @@ class Profile(models.Model): max_length=20, choices=AVATAR_OPTIONS, help_text=_(u'Select between different origins.')) - auto_sync = models.BooleanField(_(u'Auto sync in app'), default=False) + auto_sync = models.BooleanField(_(u'Auto sync in app'), default=True) social_networks = models.TextField(_('social_networks blob'), blank=True, null=False) last_api_call = models.DateField(_(u'Last API call'), help_text=_(u'Last call on the API as a registered user'), auto_now_add=True, editable=False) + stripe_customer_id = models.CharField(max_length=255, blank=True) valid_until = models.DateTimeField(_(u'Valid until'), help_text=_(u'On SASS, this is the validation date'), default=timezone.now) key_verified = models.BooleanField(_(u'Key verified'), help_text=_(u'Private key has been verified and saved by user'), default=False) - stripe_customer_id = models.CharField(_(u'Stripe costumer id'), - max_length=128, - null=True, - blank=True, - help_text=_(u'Generated by Stripe\'s API')) def save(self, *args, **kwargs): if self.pk is None: diff --git a/seven23/models/profile/tests.py b/seven23/models/profile/tests.py index f1ccdd4..4e13704 100644 --- a/seven23/models/profile/tests.py +++ b/seven23/models/profile/tests.py @@ -27,7 +27,7 @@ def test_profile_creation(self): self.assertEqual(self.user.profile.valid_until > timezone.now(), True) self.assertEqual(self.user.profile.valid_until < expected_date, True) - self.assertEqual(self.user.profile.auto_sync, False) + self.assertEqual(self.user.profile.auto_sync, True) self.assertEqual(self.user.profile.key_verified, False) self.user.profile.key_verified = True diff --git a/seven23/models/rest_auth/serializers.py b/seven23/models/rest_auth/serializers.py index 3ce2d38..d4507b4 100644 --- a/seven23/models/rest_auth/serializers.py +++ b/seven23/models/rest_auth/serializers.py @@ -27,7 +27,6 @@ from seven23.models.currency.models import Currency from seven23.models.currency.serializers import CurrencySerializer -from seven23.models.saas.serializers import ChargeSerializer from seven23.models.profile.serializers import DatetimeSerializer, ProfileSerializer from rest_framework import serializers @@ -45,13 +44,12 @@ class UserSerializer(WritableNestedModelSerializer): favoritesCurrencies = serializers.PrimaryKeyRelatedField(many=True, queryset=Currency.objects.all()) verified = serializers.SerializerMethodField() valid_until = serializers.SerializerMethodField() - charges = serializers.SerializerMethodField() profile = ProfileSerializer() class Meta: model = UserModel - fields = ('pk', 'username', 'first_name', 'email', 'verified', 'favoritesCurrencies', 'profile', 'valid_until', 'charges') - read_only_fields = ('email', 'charges') + fields = ('pk', 'username', 'first_name', 'email', 'verified', 'favoritesCurrencies', 'profile', 'valid_until') + read_only_fields = ['email'] def get_verified(self, obj): try: @@ -62,9 +60,6 @@ def get_verified(self, obj): def get_valid_until(self, obj): return DatetimeSerializer(obj.profile).data['valid_until'] - def get_charges(self, obj): - return [ChargeSerializer(charge).data for charge in obj.charges.all()] - class PasswordResetSerializer(serializers.Serializer): """ diff --git a/seven23/models/saas/admin.py b/seven23/models/saas/admin.py index 04b09d7..52dd2a5 100644 --- a/seven23/models/saas/admin.py +++ b/seven23/models/saas/admin.py @@ -2,19 +2,7 @@ Terms and Conditions administration """ from django.contrib import admin -from seven23.models.saas.models import Charge, Product, Coupon +from seven23.models.saas.models import StripeSubscription, Price -class ChargeAdmin(admin.ModelAdmin): - search_fields = ['reference_id'] - list_display = ('user', 'status', 'date', 'paiment_method', 'apply_coupon', 'coupon') - -class ProductAdmin(admin.ModelAdmin): - list_display = ('duration', 'price', 'currency') - -class CouponAdmin(admin.ModelAdmin): - list_display = ('code', 'percent_off', 'is_active') - - -admin.site.register(Charge, ChargeAdmin) -admin.site.register(Product, ProductAdmin) -admin.site.register(Coupon, CouponAdmin) \ No newline at end of file +admin.site.register(StripeSubscription) +admin.site.register(Price) \ No newline at end of file diff --git a/seven23/models/saas/migrations/0007_alter_product_price.py b/seven23/models/saas/migrations/0007_alter_product_price.py new file mode 100644 index 0000000..5edf04d --- /dev/null +++ b/seven23/models/saas/migrations/0007_alter_product_price.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.7 on 2023-11-27 05:02 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('saas', '0006_auto_20200705_1039'), + ] + + operations = [ + migrations.AlterField( + model_name='product', + name='price', + field=models.FloatField(verbose_name='Price'), + ), + ] diff --git a/seven23/models/saas/migrations/0008_remove_charge_coupon_alter_charge_paiment_method_and_more.py b/seven23/models/saas/migrations/0008_remove_charge_coupon_alter_charge_paiment_method_and_more.py new file mode 100644 index 0000000..150afe9 --- /dev/null +++ b/seven23/models/saas/migrations/0008_remove_charge_coupon_alter_charge_paiment_method_and_more.py @@ -0,0 +1,25 @@ +# Generated by Django 4.2.7 on 2023-11-27 05:47 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('saas', '0007_alter_product_price'), + ] + + operations = [ + migrations.RemoveField( + model_name='charge', + name='coupon', + ), + migrations.AlterField( + model_name='charge', + name='paiment_method', + field=models.CharField(choices=[('STRIPE', 'Stripe')], max_length=20, verbose_name='Paiment method'), + ), + migrations.DeleteModel( + name='Coupon', + ), + ] diff --git a/seven23/models/saas/migrations/0009_delete_charge.py b/seven23/models/saas/migrations/0009_delete_charge.py new file mode 100644 index 0000000..796a8a3 --- /dev/null +++ b/seven23/models/saas/migrations/0009_delete_charge.py @@ -0,0 +1,16 @@ +# Generated by Django 4.2.7 on 2023-11-27 14:42 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('saas', '0008_remove_charge_coupon_alter_charge_paiment_method_and_more'), + ] + + operations = [ + migrations.DeleteModel( + name='Charge', + ), + ] diff --git a/seven23/models/saas/migrations/0010_stripecustomer.py b/seven23/models/saas/migrations/0010_stripecustomer.py new file mode 100644 index 0000000..492f24b --- /dev/null +++ b/seven23/models/saas/migrations/0010_stripecustomer.py @@ -0,0 +1,25 @@ +# Generated by Django 4.2.7 on 2023-11-27 14:49 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('saas', '0009_delete_charge'), + ] + + operations = [ + migrations.CreateModel( + name='StripeCustomer', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('stripeCustomerId', models.CharField(max_length=255)), + ('stripeSubscriptionId', models.CharField(max_length=255)), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/seven23/models/saas/migrations/0011_remove_product_currency_remove_product_duration_and_more.py b/seven23/models/saas/migrations/0011_remove_product_currency_remove_product_duration_and_more.py new file mode 100644 index 0000000..ad5f33f --- /dev/null +++ b/seven23/models/saas/migrations/0011_remove_product_currency_remove_product_duration_and_more.py @@ -0,0 +1,45 @@ +# Generated by Django 4.2.7 on 2023-11-28 03:20 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('saas', '0010_stripecustomer'), + ] + + operations = [ + migrations.RemoveField( + model_name='product', + name='currency', + ), + migrations.RemoveField( + model_name='product', + name='duration', + ), + migrations.RemoveField( + model_name='product', + name='price', + ), + migrations.AlterField( + model_name='stripecustomer', + name='user', + field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='stripe', to=settings.AUTH_USER_MODEL), + ), + migrations.CreateModel( + name='Price', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('stripe_price_id', models.CharField(help_text='Looks like prod_HKHuxHWnp5SnIL', max_length=128, verbose_name='Stripe price id')), + ('price', models.FloatField(verbose_name='Price')), + ('currency', models.CharField(default='EUR', max_length=3, verbose_name='Currency')), + ('duration', models.IntegerField(default=12, help_text='Per month', verbose_name='How many month to add')), + ('enabled', models.BooleanField(default=True, verbose_name='Enabled')), + ('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='prices', to='saas.product')), + ], + ), + ] diff --git a/seven23/models/saas/migrations/0012_remove_price_product_delete_product.py b/seven23/models/saas/migrations/0012_remove_price_product_delete_product.py new file mode 100644 index 0000000..9502b23 --- /dev/null +++ b/seven23/models/saas/migrations/0012_remove_price_product_delete_product.py @@ -0,0 +1,20 @@ +# Generated by Django 4.2.7 on 2023-11-28 03:37 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('saas', '0011_remove_product_currency_remove_product_duration_and_more'), + ] + + operations = [ + migrations.RemoveField( + model_name='price', + name='product', + ), + migrations.DeleteModel( + name='Product', + ), + ] diff --git a/seven23/models/saas/migrations/0013_rename_stripecustomerid_stripecustomer_stripe_customer_id_and_more.py b/seven23/models/saas/migrations/0013_rename_stripecustomerid_stripecustomer_stripe_customer_id_and_more.py new file mode 100644 index 0000000..4442a21 --- /dev/null +++ b/seven23/models/saas/migrations/0013_rename_stripecustomerid_stripecustomer_stripe_customer_id_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 4.2.7 on 2023-11-28 04:51 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('saas', '0012_remove_price_product_delete_product'), + ] + + operations = [ + migrations.RenameField( + model_name='stripecustomer', + old_name='stripeCustomerId', + new_name='stripe_customer_id', + ), + migrations.RenameField( + model_name='stripecustomer', + old_name='stripeSubscriptionId', + new_name='stripe_subscription_id', + ), + migrations.AlterField( + model_name='price', + name='stripe_price_id', + field=models.CharField(help_text='Looks like price_*', max_length=128, verbose_name='Stripe price id'), + ), + ] diff --git a/seven23/models/saas/migrations/0014_stripecustomer_is_active.py b/seven23/models/saas/migrations/0014_stripecustomer_is_active.py new file mode 100644 index 0000000..8503ade --- /dev/null +++ b/seven23/models/saas/migrations/0014_stripecustomer_is_active.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.7 on 2023-11-28 06:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('saas', '0013_rename_stripecustomerid_stripecustomer_stripe_customer_id_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='stripecustomer', + name='is_active', + field=models.BooleanField(default=False), + ), + ] diff --git a/seven23/models/saas/migrations/0015_stripecustomer_price_alter_price_stripe_price_id.py b/seven23/models/saas/migrations/0015_stripecustomer_price_alter_price_stripe_price_id.py new file mode 100644 index 0000000..21aa22b --- /dev/null +++ b/seven23/models/saas/migrations/0015_stripecustomer_price_alter_price_stripe_price_id.py @@ -0,0 +1,24 @@ +# Generated by Django 4.2.7 on 2023-11-28 11:24 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('saas', '0014_stripecustomer_is_active'), + ] + + operations = [ + migrations.AddField( + model_name='stripecustomer', + name='price', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='customers', to='saas.price'), + ), + migrations.AlterField( + model_name='price', + name='stripe_price_id', + field=models.CharField(help_text='Looks like price_*', max_length=128, unique=True, verbose_name='Stripe price id'), + ), + ] diff --git a/seven23/models/saas/migrations/0016_alter_stripecustomer_is_active.py b/seven23/models/saas/migrations/0016_alter_stripecustomer_is_active.py new file mode 100644 index 0000000..c905b91 --- /dev/null +++ b/seven23/models/saas/migrations/0016_alter_stripecustomer_is_active.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.7 on 2023-11-29 10:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('saas', '0015_stripecustomer_price_alter_price_stripe_price_id'), + ] + + operations = [ + migrations.AlterField( + model_name='stripecustomer', + name='is_active', + field=models.BooleanField(default=True), + ), + ] diff --git a/seven23/models/saas/migrations/0017_stripesubscription_delete_stripecustomer.py b/seven23/models/saas/migrations/0017_stripesubscription_delete_stripecustomer.py new file mode 100644 index 0000000..f031e77 --- /dev/null +++ b/seven23/models/saas/migrations/0017_stripesubscription_delete_stripecustomer.py @@ -0,0 +1,33 @@ +# Generated by Django 4.2.7 on 2023-12-03 10:25 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('saas', '0016_alter_stripecustomer_is_active'), + ] + + operations = [ + migrations.CreateModel( + name='StripeSubscription', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('subscription_id', models.CharField(max_length=255)), + ('trial_end', models.DateTimeField(null=True)), + ('current_period_end', models.DateTimeField(null=True)), + ('cancel_at', models.DateTimeField(null=True)), + ('status', models.CharField(max_length=255)), + ('is_active', models.BooleanField(default=True)), + ('price', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='customers', to='saas.price')), + ('user', models.OneToOneField(blank=True, on_delete=django.db.models.deletion.CASCADE, related_name='stripe', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.DeleteModel( + name='StripeCustomer', + ), + ] diff --git a/seven23/models/saas/migrations/0018_alter_stripesubscription_user.py b/seven23/models/saas/migrations/0018_alter_stripesubscription_user.py new file mode 100644 index 0000000..404fe4b --- /dev/null +++ b/seven23/models/saas/migrations/0018_alter_stripesubscription_user.py @@ -0,0 +1,21 @@ +# Generated by Django 4.2.7 on 2023-12-03 11:15 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('saas', '0017_stripesubscription_delete_stripecustomer'), + ] + + operations = [ + migrations.AlterField( + model_name='stripesubscription', + name='user', + field=models.OneToOneField(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='stripe', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/seven23/models/saas/models.py b/seven23/models/saas/models.py index 5b18764..120d113 100644 --- a/seven23/models/saas/models.py +++ b/seven23/models/saas/models.py @@ -15,111 +15,47 @@ def add_months(sourcedate, months): day = min(sourcedate.day, calendar.monthrange(year,month)[1]) return timezone.make_aware(datetime.datetime(year, month, day, sourcedate.hour, sourcedate.minute, sourcedate.second)) -class Coupon(models.Model): - - """ - Discount token on puschase in SAAS mode. - """ - code = models.CharField(_(u'id'), - unique=True, - max_length=128, - help_text=_(u'Ex: FALL25OFF')) - name = models.CharField(_(u'Name'), help_text=_(u'Max 128 characters'), max_length=128) - percent_off = models.IntegerField(_(u'Percentage off'), help_text=_(u'between 0 and 100'), default=0) - valid_until = models.DateField(_(u'Valid until'), null=True, blank=True) - max_redemptions = models.IntegerField(_(u'Max redemptions'), null=True, blank=True) - affiliate = models.ForeignKey(User, - blank=True, - null=True, on_delete=models.CASCADE) - affiliate_percent = models.IntegerField(_(u'Affiliate percent'), help_text=_(u'between 0 and 100'), default=0) - enabled = models.BooleanField(_(u'Enabled'), default=True) - - def is_active(self): - return self.enabled and \ - (not self.valid_until or self.valid_until < datetime.datetime.now()) and \ - (not self.max_redemptions or len(self.charges.all()) < self.max_redemptions) - - def __str__(self): - return u'%s' % (self.code) - - def save(self, *args, **kwargs): - if (self.affiliate_percent + self.percent_off) > 100: - raise Exception('Affiliate percent and percent off can\'t make more than 100% ') - else: - super(Coupon, self).save(*args, **kwargs) # Call the "real" save() method - -class Product(models.Model): - price = models.IntegerField(_(u'Price')) +class Price(models.Model): + stripe_price_id = models.CharField(_(u'Stripe price id'), unique=True, max_length=128, help_text=_(u'Looks like price_*')) + price = models.FloatField(_(u'Price')) currency = models.CharField(_(u'Currency'), max_length=3, default='EUR') duration = models.IntegerField(_(u'How many month to add'), help_text=_(u'Per month'), default=12) - valid_until = models.DateField(_(u'Valid until'), null=True, blank=True) enabled = models.BooleanField(_(u'Enabled'), default=True) - stripe_product_id = models.CharField(_(u'Stripe product id'), max_length=128, help_text=_(u'Looks like prod_HKHuxHWnp5SnIL')) - - def is_active(self): - return self.enabled and (not self.valid_until or self.valid_until < datetime.datetime.now()) def __str__(self): - return u'%s %s' % (self.price, self.currency) + return u'%s %s %s / %s months' % (self.stripe_price_id, self.price, self.currency, self.duration) + +class StripeSubscription(models.Model): + subscription_id = models.CharField(max_length=255) + user = models.OneToOneField(to=User, related_name="stripe", null=True, on_delete=models.CASCADE) + price = models.ForeignKey(Price, related_name="customers", null=True, on_delete=models.CASCADE) + # Dates + trial_end = models.DateTimeField(null=True, blank=True) + current_period_end = models.DateTimeField(null=True, blank=True) + cancel_at = models.DateTimeField(null=True, blank=True) + # Status + status = models.CharField(max_length=255) + is_active = models.BooleanField(default=True) - def apply_coupon(self, coupon_code): - if (not coupon_code): - return self.price - coupon = Coupon.objects.get(code=coupon_code) - return self.price - (self.price * coupon.percent_off / 100) - - def delete(self, *args, **kwargs): - """ - If a category object is link to a transaction, it cannot be delete because - we need to keep trace of all transactions. It is only disable/hide. - """ - if self.charges.all(): - self.enabled = False - self.save() - else: - super(Product, self).delete(*args, **kwargs) # Call the "real" save() method - - -class Charge(models.Model): - """ - When a user try to buy a product - """ - - TYPE = ( - ('COUPON', 'Coupon'), - ('STRIPE', 'Stripe'), - ) - - STATUS = ( - ('SUCCESS', 'Success'), - ('PENDING', 'Pending'), - ('FAILED', 'Failed'), - ('CANCELED', 'Canceled'), - ) - user = models.ForeignKey(User, related_name="charges", on_delete=models.CASCADE) - product = models.ForeignKey(Product, related_name="charges", on_delete=models.CASCADE) - coupon = models.ForeignKey(Coupon, related_name="charges", null=True, blank=True, on_delete=models.CASCADE) - date = models.DateTimeField(_(u'Date'), auto_now_add=True, editable=False) - paiment_method = models.CharField(_(u'Paiment method'), max_length=20, choices=TYPE) - reference_id = models.CharField(_(u'Reference ID'), max_length=128, null=True, blank=True) - status = models.CharField(_(u'Status'), max_length=20, choices=STATUS) - comment = models.TextField(_(u'Comment'), help_text=_(u'Will give context to the user'), null=True, blank=True) - stripe_session_id = models.CharField(_(u'Stripe session id'), max_length=128, help_text=_(u'Generated by Stripe API')) - - def __init__(self, *args, **kwargs): - super(Charge, self).__init__(*args, **kwargs) - self.__original_status = self.status + def __str__(self): + return u'%s %s' % (self.user, self.subscription_id) - def apply_coupon(self): - if (not self.coupon): - return self.product.price - return self.product.apply_coupon(self.coupon.code) + def is_trial(self): + return self.trial_end == self.current_period_end - def __str__(self): - return u'%s %s' % (self.user, self.date) + def is_canceled(self): + return self.cancel_at is not None def save(self, *args, **kwargs): - if self.status == "SUCCESS" and (self.__original_status != "SUCCESS" or not self.pk): - self.user.profile.valid_until = add_months(max(self.user.profile.valid_until, timezone.now()), self.product.duration) - self.user.save() - super(Charge, self).save(*args, **kwargs) # Call the "real" save() method \ No newline at end of file + if self.user: + valid_until = self.user.profile.valid_until + if self.cancel_at: + self.is_active = False + self.user.profile.valid_until = self.cancel_at + elif self.trial_end and self.trial_end > self.current_period_end: + self.user.profile.valid_until = self.trial_end + elif self.current_period_end: + self.user.profile.valid_until = self.current_period_end + self.user.profile.save() + + super(StripeSubscription, self).save(*args, **kwargs) # Call the "real" save() method \ No newline at end of file diff --git a/seven23/models/saas/serializers.py b/seven23/models/saas/serializers.py index ef22836..099cab9 100644 --- a/seven23/models/saas/serializers.py +++ b/seven23/models/saas/serializers.py @@ -2,37 +2,21 @@ Serializer for Currency module """ from rest_framework import serializers -from seven23.models.saas.models import Charge, Product, Coupon +from seven23.models.saas.models import Price, StripeSubscription -class ProductSerializer(serializers.HyperlinkedModelSerializer): +class StripeSubscriptionSerializer(serializers.HyperlinkedModelSerializer): """ Serialize Currency model """ - class Meta: - model = Product - fields = ('pk', 'price', 'currency', 'duration', 'is_active') -class CouponSerializer(serializers.HyperlinkedModelSerializer): - """ - Serialize Currency model - """ class Meta: - model = Coupon - fields = ('code', 'name', 'percent_off', 'affiliate', 'affiliate_percent', 'is_active') + model = StripeSubscription + fields = ('pk', 'subscription_id', 'current_period_end', 'is_active', 'is_trial', 'is_canceled') -class ChargeSerializer(serializers.HyperlinkedModelSerializer): +class PriceSerializer(serializers.HyperlinkedModelSerializer): """ Serialize Currency model """ - product = serializers.PrimaryKeyRelatedField(queryset=Product.objects.all()) - coupon = serializers.PrimaryKeyRelatedField(queryset=Coupon.objects.all()) - apply_coupon = serializers.SerializerMethodField() - product = ProductSerializer() - coupon = CouponSerializer() - - def get_apply_coupon(self, obj): - return obj.apply_coupon() - class Meta: - model = Charge - fields = ('pk', 'product', 'coupon', 'apply_coupon', 'date', 'paiment_method', 'reference_id', 'status', 'comment') \ No newline at end of file + model = Price + fields = ('pk', 'stripe_price_id', 'price', 'currency', 'duration', 'enabled') \ No newline at end of file diff --git a/seven23/settings.py b/seven23/settings.py index 3cff48e..5bd1f97 100644 --- a/seven23/settings.py +++ b/seven23/settings.py @@ -30,8 +30,8 @@ integrations=[DjangoIntegration()] ) -VERSION = [1, 4, 0] -API_VERSION = [1, 0, 0] +VERSION = [1, 5, 0] +API_VERSION = [1, 1, 0] BASE_DIR = os.path.dirname(os.path.dirname(__file__)) @@ -61,9 +61,18 @@ ALLOWED_HOSTS = ['*'] SAAS = os.environ.get('SAAS', 'false').lower() == 'true' -TRIAL_PERIOD = 30 +# Get env TRIAL_PERIOD and set to integer and default value is 7 +TRIAL_PERIOD = int(os.environ.get('TRIAL_PERIOD', 7)) STRIPE_PUBLIC_KEY = os.environ.get('STRIPE_PUBLIC_KEY') STRIPE_SECRET_KEY = os.environ.get('STRIPE_SECRET_KEY') +STRIPE_PRODUCT = os.environ.get('STRIPE_PRODUCT') + +if SAAS and not STRIPE_PUBLIC_KEY: + errors.append("STRIPE_PUBLIC_KEY") +if SAAS and not STRIPE_SECRET_KEY: + errors.append("STRIPE_SECRET_KEY") +if SAAS and not STRIPE_PRODUCT: + errors.append("STRIPE_PRODUCT") DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' diff --git a/seven23/views.py b/seven23/views.py index 0745934..0a50005 100644 --- a/seven23/views.py +++ b/seven23/views.py @@ -11,7 +11,6 @@ from django.utils import timezone from django import forms -from seven23.models.saas.models import Product from seven23.models.terms.models import TermsAndConditions from seven23.models.currency.models import Currency from seven23.models.users.forms import SuperUserForm