├── djangostripe ├── __init__.py ├── urls.py ├── asgi.py ├── wsgi.py └── settings.py ├── subscriptions ├── __init__.py ├── migrations │ ├── __init__.py │ └── 0001_initial.py ├── tests.py ├── apps.py ├── admin.py ├── urls.py ├── models.py └── views.py ├── .gitignore ├── requirements.txt ├── manage.py ├── static └── main.js ├── templates ├── cancel.html ├── success.html └── home.html ├── README.md └── LICENSE /djangostripe/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /subscriptions/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /subscriptions/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | env 2 | venv/ 3 | .env 4 | .idea/ 5 | __pycache__ 6 | *.sqlite3 -------------------------------------------------------------------------------- /subscriptions/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Django==4.0.6 2 | django-allauth==0.51.0 3 | requests==2.28.1 4 | stripe==3.5.0 5 | -------------------------------------------------------------------------------- /subscriptions/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class SubscriptionsConfig(AppConfig): 5 | name = 'subscriptions' 6 | -------------------------------------------------------------------------------- /subscriptions/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from subscriptions.models import StripeCustomer 3 | 4 | 5 | admin.site.register(StripeCustomer) 6 | -------------------------------------------------------------------------------- /djangostripe/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.urls import path, include 3 | 4 | urlpatterns = [ 5 | path('admin/', admin.site.urls), 6 | path('', include('subscriptions.urls')), 7 | path('accounts/', include('allauth.urls')), 8 | ] 9 | -------------------------------------------------------------------------------- /subscriptions/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | from . import views 3 | 4 | urlpatterns = [ 5 | path('', views.home, name='subscriptions-home'), 6 | path('config/', views.stripe_config), 7 | path('create-checkout-session/', views.create_checkout_session), 8 | path('success/', views.success), 9 | path('cancel/', views.cancel), 10 | path('webhook/', views.stripe_webhook), 11 | ] 12 | -------------------------------------------------------------------------------- /subscriptions/models.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import User 2 | from django.db import models 3 | 4 | 5 | class StripeCustomer(models.Model): 6 | user = models.OneToOneField(to=User, on_delete=models.CASCADE) 7 | stripeCustomerId = models.CharField(max_length=255) 8 | stripeSubscriptionId = models.CharField(max_length=255) 9 | 10 | def __str__(self): 11 | return self.user.username 12 | -------------------------------------------------------------------------------- /djangostripe/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for djangostripe project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.1/howto/deployment/asgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.asgi import get_asgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'djangostripe.settings') 15 | 16 | application = get_asgi_application() 17 | -------------------------------------------------------------------------------- /djangostripe/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for djangostripe project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.1/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'djangostripe.settings') 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | """Run administrative tasks.""" 9 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'djangostripe.settings') 10 | try: 11 | from django.core.management import execute_from_command_line 12 | except ImportError as exc: 13 | raise ImportError( 14 | "Couldn't import Django. Are you sure it's installed and " 15 | "available on your PYTHONPATH environment variable? Did you " 16 | "forget to activate a virtual environment?" 17 | ) from exc 18 | execute_from_command_line(sys.argv) 19 | 20 | 21 | if __name__ == '__main__': 22 | main() 23 | -------------------------------------------------------------------------------- /static/main.js: -------------------------------------------------------------------------------- 1 | console.log("Sanity check!"); 2 | 3 | // Get Stripe publishable key 4 | fetch("/config/") 5 | .then((result) => { return result.json(); }) 6 | .then((data) => { 7 | // Initialize Stripe.js 8 | const stripe = Stripe(data.publicKey); 9 | 10 | // Event handler 11 | let submitBtn = document.querySelector("#submitBtn"); 12 | if (submitBtn !== null) { 13 | submitBtn.addEventListener("click", () => { 14 | // Get Checkout Session ID 15 | fetch("/create-checkout-session/") 16 | .then((result) => { return result.json(); }) 17 | .then((data) => { 18 | console.log(data); 19 | // Redirect to Stripe Checkout 20 | return stripe.redirectToCheckout({sessionId: data.sessionId}) 21 | }) 22 | .then((res) => { 23 | console.log(res); 24 | }); 25 | }); 26 | } 27 | }); 28 | -------------------------------------------------------------------------------- /subscriptions/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1 on 2020-10-01 14:53 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [ 13 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name='StripeCustomer', 19 | fields=[ 20 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 21 | ('stripeCustomerId', models.CharField(max_length=255)), 22 | ('stripeSubscriptionId', models.CharField(max_length=255)), 23 | ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), 24 | ], 25 | ), 26 | ] 27 | -------------------------------------------------------------------------------- /templates/cancel.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Django + Stripe Subscriptions 7 | 8 | 9 | 10 | 11 | 12 |
13 |

You have cancelled the checkout.

14 |

Return to the dashboard

15 |
16 | 17 | 18 | -------------------------------------------------------------------------------- /templates/success.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Django + Stripe Subscriptions 7 | 8 | 9 | 10 | 11 | 12 |
13 |

You have successfully subscribed!

14 |

Return to the dashboard

15 |
16 | 17 | 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Setting up Stripe subscriptions with Django 2 | 3 | ## Want to learn how to build this? 4 | 5 | Check out the [post](https://testdriven.io/blog/django-stripe-subscriptions/). 6 | 7 | ## Want to use this project? 8 | 9 | 1. Fork/Clone 10 | 11 | 1. Create and activate a virtual environment: 12 | 13 | ```sh 14 | $ python3 -m venv venv && source venv/bin/activate 15 | ``` 16 | 17 | 1. Install the requirements: 18 | 19 | ```sh 20 | (venv)$ pip install -r requirements.txt 21 | ``` 22 | 23 | 1. Apply the migrations: 24 | 25 | ```sh 26 | (venv)$ python manage.py migrate 27 | ``` 28 | 29 | 1. Add your Stripe test secret key, test publishable key, endpoint secret and price API ID to the *settings.py* file: 30 | 31 | ```python 32 | STRIPE_PUBLISHABLE_KEY = '' 33 | STRIPE_SECRET_KEY = '' 34 | STRIPE_PRICE_ID = '' 35 | STRIPE_ENDPOINT_SECRET = '' 36 | ``` 37 | 38 | 1. Run the server: 39 | 40 | ```sh 41 | (venv)$ python manage.py runserver 42 | ``` 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Nik Tomazic 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /templates/home.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | 3 | 4 | 5 | 6 | 7 | 8 | Django + Stripe Subscriptions 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | {% if subscription.status == "active" %} 18 |

Your subscription:

19 |
20 |
21 |
{{ product.name }}
22 |

23 | {{ product.description }} 24 |

25 |
26 |
27 | {% else %} 28 | 29 | {% endif %} 30 |
31 | 32 | 33 | -------------------------------------------------------------------------------- /subscriptions/views.py: -------------------------------------------------------------------------------- 1 | import stripe 2 | from django.conf import settings 3 | from django.contrib.auth.decorators import login_required 4 | from django.contrib.auth.models import User # new 5 | from django.http.response import JsonResponse, HttpResponse # updated 6 | from django.shortcuts import render 7 | from django.views.decorators.csrf import csrf_exempt 8 | 9 | from subscriptions.models import StripeCustomer # new 10 | 11 | 12 | @login_required 13 | def home(request): 14 | try: 15 | # Retrieve the subscription & product 16 | stripe_customer = StripeCustomer.objects.get(user=request.user) 17 | stripe.api_key = settings.STRIPE_SECRET_KEY 18 | subscription = stripe.Subscription.retrieve(stripe_customer.stripeSubscriptionId) 19 | product = stripe.Product.retrieve(subscription.plan.product) 20 | 21 | # Feel free to fetch any additional data from 'subscription' or 'product' 22 | # https://stripe.com/docs/api/subscriptions/object 23 | # https://stripe.com/docs/api/products/object 24 | 25 | return render(request, 'home.html', { 26 | 'subscription': subscription, 27 | 'product': product, 28 | }) 29 | 30 | except StripeCustomer.DoesNotExist: 31 | return render(request, 'home.html') 32 | 33 | 34 | @csrf_exempt 35 | def stripe_config(request): 36 | if request.method == 'GET': 37 | stripe_config = {'publicKey': settings.STRIPE_PUBLISHABLE_KEY} 38 | return JsonResponse(stripe_config, safe=False) 39 | 40 | 41 | @csrf_exempt 42 | def create_checkout_session(request): 43 | if request.method == 'GET': 44 | domain_url = 'http://localhost:8000/' 45 | stripe.api_key = settings.STRIPE_SECRET_KEY 46 | try: 47 | checkout_session = stripe.checkout.Session.create( 48 | client_reference_id=request.user.id if request.user.is_authenticated else None, 49 | success_url=domain_url + 'success?session_id={CHECKOUT_SESSION_ID}', 50 | cancel_url=domain_url + 'cancel/', 51 | payment_method_types=['card'], 52 | mode='subscription', 53 | line_items=[ 54 | { 55 | 'price': settings.STRIPE_PRICE_ID, 56 | 'quantity': 1, 57 | } 58 | ] 59 | ) 60 | return JsonResponse({'sessionId': checkout_session['id']}) 61 | except Exception as e: 62 | return JsonResponse({'error': str(e)}) 63 | 64 | 65 | @login_required 66 | def success(request): 67 | return render(request, 'success.html') 68 | 69 | 70 | @login_required 71 | def cancel(request): 72 | return render(request, 'cancel.html') 73 | 74 | 75 | @csrf_exempt 76 | def stripe_webhook(request): 77 | stripe.api_key = settings.STRIPE_SECRET_KEY 78 | endpoint_secret = settings.STRIPE_ENDPOINT_SECRET 79 | payload = request.body 80 | sig_header = request.META['HTTP_STRIPE_SIGNATURE'] 81 | event = None 82 | 83 | try: 84 | event = stripe.Webhook.construct_event( 85 | payload, sig_header, endpoint_secret 86 | ) 87 | except ValueError as e: 88 | # Invalid payload 89 | return HttpResponse(status=400) 90 | except stripe.error.SignatureVerificationError as e: 91 | # Invalid signature 92 | return HttpResponse(status=400) 93 | 94 | # Handle the checkout.session.completed event 95 | if event['type'] == 'checkout.session.completed': 96 | session = event['data']['object'] 97 | 98 | # Fetch all the required data from session 99 | client_reference_id = session.get('client_reference_id') 100 | stripe_customer_id = session.get('customer') 101 | stripe_subscription_id = session.get('subscription') 102 | 103 | # Get the user and create a new StripeCustomer 104 | user = User.objects.get(id=client_reference_id) 105 | StripeCustomer.objects.create( 106 | user=user, 107 | stripeCustomerId=stripe_customer_id, 108 | stripeSubscriptionId=stripe_subscription_id, 109 | ) 110 | print(user.username + ' just subscribed.') 111 | 112 | return HttpResponse(status=200) 113 | -------------------------------------------------------------------------------- /djangostripe/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for djangostripe project. 3 | 4 | Generated by 'django-admin startproject' using Django 3.1. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.1/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/3.1/ref/settings/ 11 | """ 12 | 13 | from pathlib import Path 14 | 15 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 16 | BASE_DIR = Path(__file__).resolve(strict=True).parent.parent 17 | 18 | 19 | # Quick-start development settings - unsuitable for production 20 | # See https://docs.djangoproject.com/en/3.1/howto/deployment/checklist/ 21 | 22 | # SECURITY WARNING: keep the secret key used in production secret! 23 | SECRET_KEY = '&zh09&src7nab9=*&@a5yj8(-aqy35a_g_jkx(%(a*hj306q!x' 24 | 25 | # SECURITY WARNING: don't run with debug turned on in production! 26 | DEBUG = True 27 | 28 | ALLOWED_HOSTS = [] 29 | 30 | 31 | # Application definition 32 | 33 | INSTALLED_APPS = [ 34 | 'django.contrib.admin', 35 | 'django.contrib.auth', 36 | 'django.contrib.contenttypes', 37 | 'django.contrib.sessions', 38 | 'django.contrib.messages', 39 | 'django.contrib.staticfiles', 40 | 'django.contrib.sites', 41 | 'allauth', 42 | 'allauth.account', 43 | 'allauth.socialaccount', 44 | 'subscriptions.apps.SubscriptionsConfig', 45 | ] 46 | 47 | MIDDLEWARE = [ 48 | 'django.middleware.security.SecurityMiddleware', 49 | 'django.contrib.sessions.middleware.SessionMiddleware', 50 | 'django.middleware.common.CommonMiddleware', 51 | 'django.middleware.csrf.CsrfViewMiddleware', 52 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 53 | 'django.contrib.messages.middleware.MessageMiddleware', 54 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 55 | ] 56 | 57 | ROOT_URLCONF = 'djangostripe.urls' 58 | 59 | TEMPLATES = [ 60 | { 61 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 62 | 'DIRS': ['templates'], 63 | 'APP_DIRS': True, 64 | 'OPTIONS': { 65 | 'context_processors': [ 66 | 'django.template.context_processors.debug', 67 | 'django.template.context_processors.request', 68 | 'django.contrib.auth.context_processors.auth', 69 | 'django.contrib.messages.context_processors.messages', 70 | ], 71 | }, 72 | }, 73 | ] 74 | 75 | WSGI_APPLICATION = 'djangostripe.wsgi.application' 76 | 77 | 78 | # Database 79 | # https://docs.djangoproject.com/en/3.1/ref/settings/#databases 80 | 81 | DATABASES = { 82 | 'default': { 83 | 'ENGINE': 'django.db.backends.sqlite3', 84 | 'NAME': BASE_DIR / 'db.sqlite3', 85 | } 86 | } 87 | 88 | 89 | # Password validation 90 | # https://docs.djangoproject.com/en/3.1/ref/settings/#auth-password-validators 91 | 92 | AUTH_PASSWORD_VALIDATORS = [ 93 | { 94 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 95 | }, 96 | { 97 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 98 | }, 99 | { 100 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', 101 | }, 102 | { 103 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 104 | }, 105 | ] 106 | 107 | 108 | # Internationalization 109 | # https://docs.djangoproject.com/en/3.1/topics/i18n/ 110 | 111 | LANGUAGE_CODE = 'en-us' 112 | 113 | TIME_ZONE = 'UTC' 114 | 115 | USE_I18N = True 116 | 117 | USE_L10N = True 118 | 119 | USE_TZ = True 120 | 121 | 122 | # Static files (CSS, JavaScript, Images) 123 | # https://docs.djangoproject.com/en/3.1/howto/static-files/ 124 | 125 | STATIC_URL = '/static/' 126 | STATICFILES_DIRS = [Path(BASE_DIR).joinpath('static')] 127 | 128 | STRIPE_PUBLISHABLE_KEY = '' 129 | STRIPE_SECRET_KEY = '' 130 | STRIPE_PRICE_ID = '' 131 | STRIPE_ENDPOINT_SECRET = '' 132 | 133 | AUTHENTICATION_BACKENDS = [ 134 | # Needed to login by username in Django admin, regardless of `allauth` 135 | 'django.contrib.auth.backends.ModelBackend', 136 | 137 | # `allauth` specific authentication methods, such as login by e-mail 138 | 'allauth.account.auth_backends.AuthenticationBackend', 139 | ] 140 | 141 | # We have to set this variable, because we enabled 'django.contrib.sites' 142 | SITE_ID = 1 143 | 144 | # User will be redirected to this page after logging in 145 | LOGIN_REDIRECT_URL = '/' 146 | 147 | # If you don't have an email server running yet add this line to avoid any possible errors. 148 | EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' 149 | --------------------------------------------------------------------------------