├── .github └── workflows │ └── test.yml ├── .gitignore ├── LICENSE ├── README.md ├── drf_stripe ├── __init__.py ├── admin.py ├── apps.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ ├── pull_stripe.py │ │ ├── update_stripe_customers.py │ │ ├── update_stripe_products.py │ │ └── update_stripe_subscriptions.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_alter_feature_description_alter_price_freq_and_more.py │ ├── 0003_price_currency.py │ └── __init__.py ├── models.py ├── serializers.py ├── settings.py ├── stripe_api │ ├── __init__.py │ ├── api.py │ ├── checkout.py │ ├── customer_portal.py │ ├── customers.py │ ├── products.py │ └── subscriptions.py ├── stripe_models │ ├── __init__.py │ ├── currency.py │ ├── customer.py │ ├── event.py │ ├── invoice.py │ ├── price.py │ ├── product.py │ └── subscription.py ├── stripe_webhooks │ ├── __init__.py │ ├── customer_subscription.py │ ├── handler.py │ ├── price.py │ └── product.py ├── urls.py └── views.py ├── env_setup.sh ├── make_package_migrations.py ├── manifest.in ├── requirements.txt ├── setup.cfg ├── setup.py ├── tests ├── __init__.py ├── api │ ├── __init__.py │ ├── test_update_customers.py │ ├── test_update_products_prices.py │ └── test_update_subscriptions.py ├── base.py ├── mock_responses │ ├── 2020-08-27 │ │ ├── webhook_price_created.json │ │ ├── webhook_price_updated.json │ │ ├── webhook_price_updated_archived.json │ │ ├── webhook_product_created.json │ │ ├── webhook_product_updated.json │ │ ├── webhook_product_updated_archived.json │ │ ├── webhook_subscription_created.json │ │ ├── webhook_subscription_updated_apply_coupon.json │ │ ├── webhook_subscription_updated_billing_frequency.json │ │ ├── webhook_subscription_updated_cancel_at_period_end.json │ │ ├── webhook_subscription_updated_cancel_immediate.json │ │ └── webhook_subscription_updated_renew_plan.json │ └── v1 │ │ ├── api_customer_list_2_items.json │ │ ├── api_price_list.json │ │ ├── api_product_list.json │ │ └── api_subscription_list.json ├── settings.py ├── urls.py └── webhook │ ├── __init__.py │ ├── test_event_product_price.py │ └── test_subscription.py └── tox.ini /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | - push 5 | - pull_request 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | strategy: 11 | fail-fast: false 12 | max-parallel: 5 13 | matrix: 14 | python-version: [ '3.8', '3.9', '3.10' ] 15 | 16 | steps: 17 | - uses: actions/checkout@v3 18 | - name: Set up Python ${{ matrix.python-version }} 19 | uses: actions/setup-python@v4 20 | with: 21 | python-version: ${{ matrix.python-version }} 22 | - name: Install dependencies 23 | run: | 24 | python -m pip install --upgrade pip 25 | python -m pip install tox==3 26 | python -m pip install --upgrade tox-gh-actions 27 | - name: Test with Tox 28 | run: tox -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | data/ 2 | environment/ 3 | whoosh/ 4 | activate*.sh 5 | activate*.bat 6 | 7 | run.bat 8 | node_modules/ 9 | frontend/package-lock.json 10 | *.elasticbeanstalk/ 11 | env/ 12 | .env.local 13 | .env.development.local 14 | .env.test.local 15 | .env.production.local 16 | *.sqlite3 17 | *.sqlite3-journal 18 | secrets/ 19 | .vscode/ 20 | .DS_Store 21 | 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | 26 | /.pnp 27 | .pnp.js 28 | 29 | # Byte-compiled / optimized / DLL files 30 | __pycache__/ 31 | *.py[cod] 32 | *$py.class 33 | 34 | # C extensions 35 | *.so 36 | 37 | # Distribution / packaging 38 | .Python 39 | build/ 40 | package/ 41 | develop-eggs/ 42 | dist/ 43 | downloads/ 44 | eggs/ 45 | .eggs/ 46 | lib/ 47 | lib64/ 48 | parts/ 49 | sdist/ 50 | var/ 51 | wheels/ 52 | *.egg-info/ 53 | .installed.cfg 54 | *.egg 55 | MANIFEST 56 | 57 | # PyInstaller 58 | # Usually these files are written by a python script from a template 59 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 60 | *.manifest 61 | *.spec 62 | 63 | # Installer logs 64 | pip-log.txt 65 | pip-delete-this-directory.txt 66 | 67 | # Unit test / coverage reports 68 | htmlcov/ 69 | .tox/ 70 | .coverage 71 | .coverage.* 72 | .cache 73 | nosetests.xml 74 | coverage.xml 75 | *.cover 76 | .hypothesis/ 77 | .pytest_cache/ 78 | /coverage 79 | 80 | # Translations 81 | *.mo 82 | *.pot 83 | 84 | # Django stuff: 85 | *.log 86 | local_settings.py 87 | db.sqlite3 88 | 89 | # Flask stuff: 90 | instance/ 91 | .webassets-cache 92 | 93 | # Scrapy stuff: 94 | .scrapy 95 | 96 | # production 97 | /build 98 | 99 | # Sphinx documentation 100 | docs/_build/ 101 | 102 | # PyBuilder 103 | target/ 104 | 105 | # Jupyter Notebook 106 | .ipynb_checkpoints 107 | 108 | # pyenv 109 | .python-version 110 | 111 | # celery beat schedule file 112 | celerybeat-schedule 113 | 114 | # SageMath parsed files 115 | *.sage.py 116 | 117 | # Environments 118 | .env 119 | .venv 120 | env/ 121 | venv/ 122 | ENV/ 123 | env.bak/ 124 | venv.bak/ 125 | 126 | # Spyder project settings 127 | .spyderproject 128 | .spyproject 129 | 130 | # Rope project settings 131 | .ropeproject 132 | 133 | # mkdocs documentation 134 | /site 135 | 136 | # mypy 137 | .mypy_cache/ 138 | 139 | # VS Code workspace 140 | *.code-workspace 141 | # Elastic Beanstalk Files 142 | .elasticbeanstalk/* 143 | !.elasticbeanstalk/*.cfg.yml 144 | !.elasticbeanstalk/*.global.yml 145 | 146 | # csv files exported 147 | NOTE*.csv 148 | 149 | .idea 150 | .pylintrc -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Oscar Y Chen 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # drf-stripe-subscription 2 | 3 | [![CI tests](https://github.com/oscarychen/drf-stripe-subscription/actions/workflows/test.yml/badge.svg)](https://github.com/oscarychen/drf-stripe-subscription/actions/workflows/test.yml) 4 | [![Package Downloads](https://img.shields.io/pypi/dm/drf-stripe-subscription)](https://pypi.org/project/drf-stripe-subscription/) 5 | 6 | An out-of-box Django REST framework solution for payment and subscription management using Stripe. The goal of this 7 | package is to utilize Stripe provided UI and features as much as possible to manage subscription product models. This 8 | package helps you make use of Stripe's hosted UI for customer checkout, billing management, as well as for admin to 9 | manage product, pricing, and customer subscriptions. 10 | 11 | - Django data models representing Stripe data objects 12 | - Supports Stripe Webhook for managing changes with your products, prices, and customer subscriptions 13 | - Django management commands for synchronizing data with Stripe 14 | - Django REST API endpoints supporting Stripe Checkout Session and Customer Portal 15 | 16 | ## Installation & Setup 17 | 18 | ```commandline 19 | pip install drf-stripe-subscription 20 | ``` 21 | 22 | Include the following drf_stripe settings in Django project settings.py: 23 | 24 | ```python 25 | DRF_STRIPE = { 26 | "STRIPE_API_SECRET": "my_stripe_api_key", 27 | "STRIPE_WEBHOOK_SECRET": "my_stripe_webhook_key", 28 | "FRONT_END_BASE_URL": "http://localhost:3000", 29 | } 30 | ``` 31 | 32 | Include drf_stripe in Django INSTALLED_APPS setting: 33 | 34 | ```python 35 | INSTALLED_APPS = ( 36 | ..., 37 | "rest_framework", 38 | "drf_stripe", 39 | ... 40 | ) 41 | ``` 42 | 43 | Include drf_stripe.url routing in Django project's urls.py, ie: 44 | 45 | ```python 46 | from django.urls import include, path 47 | 48 | urlpatterns = [ 49 | path("stripe/", include("drf_stripe.urls")), 50 | ... 51 | ] 52 | ``` 53 | 54 | Run migrations command: 55 | 56 | ```commandline 57 | python manage.py migrate 58 | ``` 59 | 60 | Pull data from Stripe into Django database using the following command: 61 | 62 | ```commandline 63 | python manage.py pull_stripe 64 | ``` 65 | 66 | Finally, start Django development server 67 | 68 | ```commandline 69 | python manage.py runserver 70 | ``` 71 | 72 | as well as Stripe CLI to forward Stripe webhook requests: 73 | 74 | ```commandline 75 | stripe listen --forward-to 127.0.0.1:8000/stripe/webhook/ 76 | ``` 77 | 78 | ## Usage 79 | 80 | The following REST API endpoints are provided: 81 | 82 | ### List product prices to subscribe 83 | 84 | ``` 85 | my-site.com/stripe/subscribable-product/ 86 | ``` 87 | 88 | This endpoint is available to both anonymous users and authenticated users. Anonymous users will see a list of all 89 | currently available products. For authenticated users, this will be a list of currently available products without any 90 | products that the user has already subscribed currently. 91 | 92 | ### List user's current subscriptions 93 | 94 | ``` 95 | my-site.com/stripe/my-subscription/ 96 | ``` 97 | 98 | This endpoint provides a list of active subscriptions for the current user. 99 | 100 | ### List user's current subscription items 101 | 102 | ``` 103 | my-site.com/stripe/my-subscription-items/ 104 | ``` 105 | 106 | This endpoint provides a list of active subscription items for the current user. 107 | 108 | ### Create a checkout session using Stripe hosted Checkout page 109 | 110 | ``` 111 | my-site.com/stripe/checkout/ 112 | ``` 113 | 114 | This endpoint creates Stripe Checkout Session 115 | 116 | Make request with the follow request data: 117 | 118 | ```{"price_id": "price_stripe_price_id_to_be_checked_out"}``` 119 | 120 | The response will contain a session_id which can be used by Stripe: 121 | 122 | ```{"session_id": "stripe_checkout_session_id"}``` 123 | 124 | This session_id is a unique identifier for a Stripe Checkout Session, and can be used 125 | by [`redirectToCheckout` in Stripe.js](https://stripe.com/docs/js/checkout/redirect_to_checkout). You can implement this 126 | in your frontend application to redirect to a Stripe hosted Checkout page after fetching the session id. 127 | 128 | By default, the Stripe Checkout page will redirect the user back to your application at 129 | either `mysite.com/payment/session={CHECKOUT_SESSION_ID}` if the checkout is successful, 130 | or `mysite.com/manage-subscription/` if checkout is cancelled. 131 | 132 | ### Stripe Customer Portal 133 | 134 | ``` 135 | mysite.com/stripe/customer-portal 136 | ``` 137 | 138 | This will create a Stripe billing portal session, and return the url to that session: 139 | 140 | ```{"url": "url_to_Stripe_billing_portal_session"``` 141 | 142 | This is a link that you can use in your frontend application to redirect a user to Stripe Customer Portal and back to 143 | your application. By default, Stripe Customer Portal will redirect the user back to your frontend application 144 | at `my-site.com/manage-subscription/` 145 | 146 | ### Stripe Webhook 147 | 148 | ``` 149 | mysite.com/stripe/webhook/ 150 | ``` 151 | 152 | This the REST API endpoint Stripe servers can call to update your Django backend application. The following Stripe 153 | webhook events are currently supported: 154 | 155 | ``` 156 | product.created 157 | product.updated 158 | product.deleted 159 | price.created 160 | price.updated 161 | price.deleted 162 | customer.subscription.created 163 | customer.subscription.updated 164 | customer.subscription.deleted 165 | ``` 166 | 167 | With these Stripe events, you can: 168 | 169 | - Manage your products and pricing model from Stripe Portal, and rely on webhook to update your Django application 170 | automatically. 171 | - Manage your customer subscriptions from Stripe Portal, and rely on webhook to update your Django application 172 | automatically. 173 | 174 | ## StripeUser 175 | 176 | The StripeUser model comes with a few attributs that allow accessing information about the user quickly: 177 | 178 | ```python 179 | from drf_stripe.models import StripeUser 180 | 181 | stripe_user = StripeUser.objects.get(user_id=django_user_id) 182 | 183 | print(stripe_user.subscription_items) 184 | print(stripe_user.current_subscription_items) 185 | print(stripe_user.subscribed_products) 186 | print(stripe_user.subscribed_features) 187 | ``` 188 | 189 | ## Customizing Checkout Session Parameters 190 | 191 | Some of the checkout parameters are specified in `DRF_STRIPE` settings: 192 | 193 | `CHECKOUT_SUCCESS_URL_PATH`: The checkout session success redirect url path. 194 | 195 | `CHECKOUT_CANCEL_URL_PATH`: The checkout session cancel redirect url path. 196 | 197 | `PAYMENT_METHOD_TYPES`: The default [payment method types](https://stripe.com/docs/api/checkout/sessions/create#create_checkout_session-payment_method_types) 198 | , defaults to `["card"]`. 199 | 200 | `DEFAULT_CHECKOUT_MODE`: The default checkout mode, defaults to `"subscription"`. 201 | 202 | By default, you can create a checkout session by calling the default REST endpoint `my-site.com/stripe/checkout/`, this 203 | REST endpoint utilizes `drf_stripe.serializers.CheckoutRequestSerializer` to validate checkout parameters and create a 204 | Stripe Checkout Session. Only a `price_id` is needed, `quantity` defaults to 1. 205 | 206 | You can extend this serializer and customize Checkout behavior, such as specifying multiple `line_items` 207 | , `payment_method_types`, and `checkout_mode`: 208 | 209 | ```python 210 | from drf_stripe.stripe_api.customers import get_or_create_stripe_user 211 | from drf_stripe.stripe_api.checkout import stripe_api_create_checkout_session 212 | from drf_stripe.serializers import CheckoutRequestSerializer 213 | from rest_framework.exceptions import ValidationError 214 | from stripe.error import StripeError 215 | 216 | 217 | class CustomCheckoutRequestSerializer(CheckoutRequestSerializer): 218 | """Handle creation of a custom checkout session where parameters are customized.""" 219 | 220 | def validate(self, attrs): 221 | stripe_user = get_or_create_stripe_user(user_id=self.context['request'].user.id) 222 | try: 223 | checkout_session = stripe_api_create_checkout_session( 224 | customer_id=stripe_user.customer_id, 225 | line_items=[ 226 | {"price_id": "stripe_price_id", "quantity": 2}, ... 227 | ], 228 | payment_method_types=["card", "alipay", ...], 229 | checkout_mode="subscription") 230 | attrs['session_id'] = checkout_session['id'] 231 | except StripeError as e: 232 | raise ValidationError(e.error) 233 | return attrs 234 | ``` 235 | 236 | For more information regarding `line_items`, `payment_method_types`, `checkout_mode`, checkout Stripe documentation for 237 | [creating a checkout session](https://stripe.com/docs/api/checkout/sessions/create). 238 | 239 | ## Product features 240 | 241 | Stripe does not come with a way of managing features specific to your products and application. drf-stripe-subscription 242 | provides additional tables to manage features associated with each Stripe Product: 243 | 244 | - Feature: this table contains feature_id and a description for the feature. 245 | - ProductFeature: this table keeps track of the many-to-many relation between Product and Feature. 246 | 247 | To assign features to a product, go to Stripe Dashboard -> `Products` -> `Add Product`/`Edit Product`: 248 | Under `Product information`, click on `Additional options`, `add metadata`. 249 | 250 | Add an entry called `features`, the value of the entry should be a space-delimited string describing a set of features, 251 | ie: `FEATURE_A FEATURE_B FEATURE_C`. 252 | 253 | If you have Stripe CLI webhook running, you should see that your Django server has automatically received product 254 | information update, and created/updated the associated ProductFeature and Feature instances. Otherwise, you can also run 255 | the `python manage.py update_stripe_products` command again to synchronize all of your product data. The `description` 256 | attribute of each Feature instance will default to the same value as `feature_id`, you should update the `description` 257 | yourself if needed. 258 | 259 | ## Django management commands 260 | 261 | ```commandline 262 | python manage.py pull_stripe 263 | ``` 264 | 265 | This command calls `update_stripe_products`, `update_stripe_customers`, `update_stripe_subscriptions` commands. 266 | 267 | ```commandline 268 | python manage.py update_stripe_products 269 | ``` 270 | 271 | Pulls products and prices from Stripe and updates Django database. 272 | 273 | ```commandline 274 | python manage.py update_stripe_customers 275 | ``` 276 | 277 | Pulls customers from Stripe and updates Django database. 278 | 279 | ```commandline 280 | python manage.py update_stripe_subscriptions 281 | ``` 282 | 283 | Pulls subscriptions from Stripe and updates Django database. 284 | 285 | ## Working with customized Django User models 286 | 287 | The following DRF_STRIPE settings can be used to customize how Django creates User instance using Stripe Customer 288 | attributes (default values shown): 289 | 290 | ```python 291 | DRF_STRIPE = { 292 | "DJANGO_USER_EMAIL_FIELD": "email", 293 | "USER_CREATE_DEFAULTS_ATTRIBUTE_MAP": {"username": "email"}, 294 | "DJANGO_USER_MODEL": None, 295 | } 296 | ``` 297 | 298 | The `DJANGO_USER_EMAIL_FIELD` specifies name of the Django User attribute to be used to store Stripe Customer email. It 299 | will be used to look up existing Django User using Stripe Customer email. 300 | 301 | The `USER_CREATE_DEFAULTS_ATTRIBUTE_MAP` maps the name of Django User attribute to name of corresponding Stripe Customer 302 | attribute, and is used during the automated Django User instance creation. 303 | 304 | The `DJANGO_USER_MODEL` is optional in case you are not using Django's default user model (nor the model you may have configured using Django's `AUTH_USER_MODEL`) for your users you wish to associate with Stripe customers. 305 | In this case specify the model you wish to use using a dotted pair - the label of the Django app (which must be in your INSTALLED_APPS), and the name of the Django model that you wish to use. 306 | For example: 307 | ```python 308 | DRF_STRIPE = { 309 | "DJANGO_USER_MODEL": "myapp.MyUser" 310 | } 311 | ``` -------------------------------------------------------------------------------- /drf_stripe/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oscarychen/drf-stripe-subscription/83cf9e3080f5fc9603790badc5323f90c3053402/drf_stripe/__init__.py -------------------------------------------------------------------------------- /drf_stripe/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.apps import apps 3 | 4 | for model in apps.get_app_config('drf_stripe').models.values(): 5 | admin.site.register(model) 6 | -------------------------------------------------------------------------------- /drf_stripe/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class DrfStripeConfig(AppConfig): 5 | name = 'drf_stripe' 6 | -------------------------------------------------------------------------------- /drf_stripe/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oscarychen/drf-stripe-subscription/83cf9e3080f5fc9603790badc5323f90c3053402/drf_stripe/management/__init__.py -------------------------------------------------------------------------------- /drf_stripe/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oscarychen/drf-stripe-subscription/83cf9e3080f5fc9603790badc5323f90c3053402/drf_stripe/management/commands/__init__.py -------------------------------------------------------------------------------- /drf_stripe/management/commands/pull_stripe.py: -------------------------------------------------------------------------------- 1 | from django.core.management import BaseCommand 2 | from django.core.management import call_command 3 | 4 | 5 | class Command(BaseCommand): 6 | help = "Pull data from Stripe and update database." 7 | 8 | def handle(self, *args, **kwargs): 9 | call_command("update_stripe_products") 10 | call_command("update_stripe_customers") 11 | call_command("update_stripe_subscriptions") 12 | -------------------------------------------------------------------------------- /drf_stripe/management/commands/update_stripe_customers.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand 2 | 3 | from drf_stripe.stripe_api.customers import stripe_api_update_customers 4 | 5 | 6 | class Command(BaseCommand): 7 | help = "Import Stripe Customer objects from Stripe" 8 | 9 | def add_arguments(self, parser): 10 | parser.add_argument("-l", "--limit", type=int, help="Limit", default=100) 11 | parser.add_argument("-s", "--starting_after", type=str, help="Starting after customer id", default=None) 12 | 13 | def handle(self, *args, **kwargs): 14 | stripe_api_update_customers(limit=kwargs.get('limit'), starting_after=kwargs.get('starting_after')) 15 | -------------------------------------------------------------------------------- /drf_stripe/management/commands/update_stripe_products.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand 2 | 3 | from drf_stripe.stripe_api.products import stripe_api_update_products_prices 4 | 5 | 6 | class Command(BaseCommand): 7 | help = "Import Service/Feature types from Stripe" 8 | 9 | def add_arguments(self, parser): 10 | pass 11 | 12 | def handle(self, *args, **kwargs): 13 | stripe_api_update_products_prices() 14 | -------------------------------------------------------------------------------- /drf_stripe/management/commands/update_stripe_subscriptions.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand 2 | 3 | from drf_stripe.stripe_api.subscriptions import stripe_api_update_subscriptions 4 | 5 | 6 | class Command(BaseCommand): 7 | help = "Import Subscription objects from Stripe" 8 | 9 | def add_arguments(self, parser): 10 | parser.add_argument("-l", "--limit", type=int, help="Limit", default=100) 11 | parser.add_argument("-s", "--starting_after", type=str, help="Starting after subscription id", default=None) 12 | 13 | def handle(self, *args, **kwargs): 14 | stripe_api_update_subscriptions(limit=kwargs.get('limit'), starting_after=kwargs.get('starting_after')) 15 | -------------------------------------------------------------------------------- /drf_stripe/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0.1 on 2022-01-13 15:48 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | from drf_stripe.models import get_drf_stripe_user_model_name 6 | 7 | 8 | class Migration(migrations.Migration): 9 | initial = True 10 | 11 | auth_user_model = get_drf_stripe_user_model_name() 12 | 13 | 14 | dependencies = [ 15 | migrations.swappable_dependency(auth_user_model), 16 | ] 17 | 18 | operations = [ 19 | migrations.CreateModel( 20 | name='Feature', 21 | fields=[ 22 | ('feature_id', models.CharField(max_length=64, primary_key=True, serialize=False)), 23 | ('description', models.CharField(max_length=256, null=True)), 24 | ], 25 | ), 26 | migrations.CreateModel( 27 | name='Price', 28 | fields=[ 29 | ('price_id', models.CharField(max_length=256, primary_key=True, serialize=False)), 30 | ('nickname', models.CharField(max_length=256, null=True)), 31 | ('price', models.PositiveIntegerField()), 32 | ('freq', models.CharField(max_length=64, null=True)), 33 | ('active', models.BooleanField()), 34 | ], 35 | ), 36 | migrations.CreateModel( 37 | name='Product', 38 | fields=[ 39 | ('product_id', models.CharField(max_length=256, primary_key=True, serialize=False)), 40 | ('active', models.BooleanField()), 41 | ('description', models.CharField(max_length=1024, null=True)), 42 | ('name', models.CharField(max_length=256, null=True)), 43 | ], 44 | ), 45 | migrations.CreateModel( 46 | name='ProductFeature', 47 | fields=[ 48 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 49 | ], 50 | ), 51 | migrations.CreateModel( 52 | name='StripeUser', 53 | fields=[ 54 | ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, 55 | related_name='stripe_user', serialize=False, 56 | to=auth_user_model)), 57 | ('customer_id', models.CharField(max_length=128, null=True)), 58 | ], 59 | ), 60 | migrations.CreateModel( 61 | name='Subscription', 62 | fields=[ 63 | ('subscription_id', models.CharField(max_length=256, primary_key=True, serialize=False)), 64 | ('period_start', models.DateTimeField(null=True)), 65 | ('period_end', models.DateTimeField(null=True)), 66 | ('cancel_at', models.DateTimeField(null=True)), 67 | ('cancel_at_period_end', models.BooleanField()), 68 | ('ended_at', models.DateTimeField(null=True)), 69 | ('status', models.CharField(max_length=64)), 70 | ('trial_end', models.DateTimeField(null=True)), 71 | ('trial_start', models.DateTimeField(null=True)), 72 | ('stripe_user', 73 | models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='subscriptions', 74 | to='drf_stripe.stripeuser')), 75 | ], 76 | ), 77 | migrations.CreateModel( 78 | name='SubscriptionItem', 79 | fields=[ 80 | ('sub_item_id', models.CharField(max_length=256, primary_key=True, serialize=False)), 81 | ('quantity', models.PositiveIntegerField()), 82 | ('price', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', 83 | to='drf_stripe.price')), 84 | ('subscription', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', 85 | to='drf_stripe.subscription')), 86 | ], 87 | ), 88 | migrations.AddIndex( 89 | model_name='stripeuser', 90 | index=models.Index(fields=['user', 'customer_id'], name='drf_stripe__user_id_6bbc0d_idx'), 91 | ), 92 | migrations.AddField( 93 | model_name='productfeature', 94 | name='feature', 95 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='linked_products', 96 | to='drf_stripe.feature'), 97 | ), 98 | migrations.AddField( 99 | model_name='productfeature', 100 | name='product', 101 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='linked_features', 102 | to='drf_stripe.product'), 103 | ), 104 | migrations.AddField( 105 | model_name='price', 106 | name='product', 107 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='prices', 108 | to='drf_stripe.product'), 109 | ), 110 | migrations.AddIndex( 111 | model_name='subscription', 112 | index=models.Index(fields=['stripe_user', 'status'], name='drf_stripe__stripe__7d71e7_idx'), 113 | ), 114 | migrations.AddIndex( 115 | model_name='price', 116 | index=models.Index(fields=['active', 'freq'], name='drf_stripe__active_3854b4_idx'), 117 | ), 118 | ] 119 | -------------------------------------------------------------------------------- /drf_stripe/migrations/0002_alter_feature_description_alter_price_freq_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0.1 on 2022-02-08 08:58 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('drf_stripe', '0001_initial'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='feature', 15 | name='description', 16 | field=models.CharField(blank=True, max_length=256, null=True), 17 | ), 18 | migrations.AlterField( 19 | model_name='price', 20 | name='freq', 21 | field=models.CharField(blank=True, max_length=64, null=True), 22 | ), 23 | migrations.AlterField( 24 | model_name='price', 25 | name='nickname', 26 | field=models.CharField(blank=True, max_length=256, null=True), 27 | ), 28 | migrations.AlterField( 29 | model_name='product', 30 | name='description', 31 | field=models.CharField(blank=True, max_length=1024, null=True), 32 | ), 33 | migrations.AlterField( 34 | model_name='product', 35 | name='name', 36 | field=models.CharField(blank=True, max_length=256, null=True), 37 | ), 38 | migrations.AlterField( 39 | model_name='subscription', 40 | name='cancel_at', 41 | field=models.DateTimeField(blank=True, null=True), 42 | ), 43 | migrations.AlterField( 44 | model_name='subscription', 45 | name='ended_at', 46 | field=models.DateTimeField(blank=True, null=True), 47 | ), 48 | migrations.AlterField( 49 | model_name='subscription', 50 | name='period_end', 51 | field=models.DateTimeField(blank=True, null=True), 52 | ), 53 | migrations.AlterField( 54 | model_name='subscription', 55 | name='period_start', 56 | field=models.DateTimeField(blank=True, null=True), 57 | ), 58 | migrations.AlterField( 59 | model_name='subscription', 60 | name='trial_end', 61 | field=models.DateTimeField(blank=True, null=True), 62 | ), 63 | migrations.AlterField( 64 | model_name='subscription', 65 | name='trial_start', 66 | field=models.DateTimeField(blank=True, null=True), 67 | ), 68 | ] 69 | -------------------------------------------------------------------------------- /drf_stripe/migrations/0003_price_currency.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0.2 on 2022-02-08 18:39 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('drf_stripe', '0002_alter_feature_description_alter_price_freq_and_more'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='price', 15 | name='currency', 16 | field=models.CharField(default='usd', max_length=3), 17 | preserve_default=False, 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /drf_stripe/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oscarychen/drf-stripe-subscription/83cf9e3080f5fc9603790badc5323f90c3053402/drf_stripe/migrations/__init__.py -------------------------------------------------------------------------------- /drf_stripe/models.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model 2 | from django.db import models 3 | from django.apps import apps as django_apps 4 | from django.conf import settings 5 | 6 | from .stripe_models.subscription import ACCESS_GRANTING_STATUSES 7 | from .settings import drf_stripe_settings 8 | 9 | 10 | def get_drf_stripe_user_model_name(): 11 | if drf_stripe_settings.DJANGO_USER_MODEL: 12 | return drf_stripe_settings.DJANGO_USER_MODEL 13 | else: 14 | return settings.AUTH_USER_MODEL 15 | 16 | 17 | def get_drf_stripe_user_model(): 18 | if drf_stripe_settings.DJANGO_USER_MODEL: 19 | return django_apps.get_model(drf_stripe_settings.DJANGO_USER_MODEL, require_ready=False) 20 | else: 21 | return get_user_model() 22 | 23 | 24 | class StripeUser(models.Model): 25 | """A model linking Django user model with a Stripe User""" 26 | user = models.OneToOneField(get_drf_stripe_user_model(), on_delete=models.CASCADE, related_name='stripe_user', 27 | primary_key=True) 28 | customer_id = models.CharField(max_length=128, null=True) 29 | 30 | @property 31 | def subscription_items(self): 32 | """Returns a set of SubscriptionItem instances associated with the StripeUser""" 33 | return SubscriptionItem.objects.filter(subscription__stripe_user=self) 34 | 35 | @property 36 | def current_subscription_items(self): 37 | """Returns a set of SubscriptionItem instances that grants current access.""" 38 | return self.subscription_items.filter(subscription__status__in=ACCESS_GRANTING_STATUSES) 39 | 40 | @property 41 | def subscribed_products(self): 42 | """Returns a set of Product instances the StripeUser currently has""" 43 | return {item.price.product for item in 44 | self.current_subscription_items.prefetch_related("price", "price__product")} 45 | 46 | @property 47 | def subscribed_features(self): 48 | """Returns a set of Feature instances the StripeUser has access to.""" 49 | price_list = self.current_subscription_items.values_list('price', flat=True) 50 | product_list = Price.objects.filter(pk__in=price_list).values_list("product", flat=True) 51 | return {item.feature for item in 52 | ProductFeature.objects.filter(product_id__in=product_list).prefetch_related("feature")} 53 | 54 | class Meta: 55 | indexes = [ 56 | models.Index(fields=['user', 'customer_id']) 57 | ] 58 | 59 | 60 | class Feature(models.Model): 61 | """ 62 | A model used to keep track of features provided by your application. 63 | This does not correspond to a Stripe object, but the feature ids should be listed as 64 | a space delimited strings in Stripe.product.metadata.features 65 | """ 66 | feature_id = models.CharField(max_length=64, primary_key=True) 67 | description = models.CharField(max_length=256, null=True, blank=True) 68 | 69 | 70 | class Product(models.Model): 71 | """A model representing a Stripe Product""" 72 | product_id = models.CharField(max_length=256, primary_key=True) 73 | active = models.BooleanField() 74 | description = models.CharField(max_length=1024, null=True, blank=True) 75 | name = models.CharField(max_length=256, null=True, blank=True) 76 | 77 | 78 | class ProductFeature(models.Model): 79 | """A model representing association of Product and Feature instances. They have many-to-many relationship.""" 80 | product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name="linked_features") 81 | feature = models.ForeignKey(Feature, on_delete=models.CASCADE, related_name="linked_products") 82 | 83 | 84 | class Price(models.Model): 85 | """A model representing to a Stripe Price object, with enhanced attributes.""" 86 | price_id = models.CharField(max_length=256, primary_key=True) 87 | product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name="prices") 88 | nickname = models.CharField(max_length=256, null=True, blank=True) # displayed name 89 | price = models.PositiveIntegerField() # price in cents, corresponding to Stripe unit_amount 90 | # billing frequency, translated from Stripe price.recurring.interval and price.recurring.interval_count 91 | freq = models.CharField(max_length=64, null=True, blank=True) 92 | active = models.BooleanField() 93 | currency = models.CharField(max_length=3) 94 | 95 | class Meta: 96 | indexes = [ 97 | models.Index(fields=['active', 'freq']) 98 | ] 99 | 100 | 101 | class Subscription(models.Model): 102 | """ 103 | A model representing Subscription, corresponding to a Stripe Subscription object. 104 | """ 105 | subscription_id = models.CharField(max_length=256, primary_key=True) 106 | stripe_user = models.ForeignKey(StripeUser, on_delete=models.CASCADE, related_name="subscriptions") 107 | period_start = models.DateTimeField(null=True, blank=True) 108 | period_end = models.DateTimeField(null=True, blank=True) 109 | cancel_at = models.DateTimeField(null=True, blank=True) 110 | cancel_at_period_end = models.BooleanField() 111 | ended_at = models.DateTimeField(null=True, blank=True) 112 | status = models.CharField(max_length=64) 113 | trial_end = models.DateTimeField(null=True, blank=True) 114 | trial_start = models.DateTimeField(null=True, blank=True) 115 | 116 | class Meta: 117 | indexes = [ 118 | models.Index(fields=['stripe_user', 'status']) 119 | ] 120 | 121 | 122 | class SubscriptionItem(models.Model): 123 | """ 124 | A model representing relation of Price and Subscription, corresponding to Stripe Subscription line item. 125 | """ 126 | sub_item_id = models.CharField(max_length=256, primary_key=True) 127 | subscription = models.ForeignKey(Subscription, on_delete=models.CASCADE, related_name="items") 128 | price = models.ForeignKey(Price, on_delete=models.CASCADE, related_name="+") 129 | quantity = models.PositiveIntegerField() 130 | -------------------------------------------------------------------------------- /drf_stripe/serializers.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | from rest_framework.exceptions import ValidationError 3 | from stripe.error import StripeError 4 | 5 | from drf_stripe.models import SubscriptionItem, Product, Price, Subscription 6 | from drf_stripe.stripe_api.checkout import stripe_api_create_checkout_session 7 | from drf_stripe.stripe_api.customers import get_or_create_stripe_user 8 | 9 | 10 | class SubscriptionSerializer(serializers.ModelSerializer): 11 | class Meta: 12 | model = Subscription 13 | fields = ( 14 | "subscription_id", "period_start", "period_end", "status", "cancel_at", "cancel_at_period_end", 15 | "trial_start", "trial_end" 16 | ) 17 | 18 | 19 | class SubscriptionItemSerializer(serializers.ModelSerializer): 20 | """Serializes SubscriptionItem model with attributes pulled from related Subscription instance""" 21 | product_id = serializers.CharField(source="price.product.product_id") 22 | product_name = serializers.CharField(source="price.product.name") 23 | product_description = serializers.CharField(source="price.product.description") 24 | price_id = serializers.CharField(source="price.price_id") 25 | price_nickname = serializers.CharField(source="price.nickname") 26 | price = serializers.CharField(source="price.price") 27 | freq = serializers.CharField(source="price.freq") 28 | services = serializers.SerializerMethodField(method_name='get_feature_ids') 29 | subscription_status = serializers.CharField(source='subscription.status') 30 | period_start = serializers.DateTimeField(source='subscription.period_start') 31 | period_end = serializers.DateTimeField(source='subscription.period_end') 32 | trial_start = serializers.DateTimeField(source='subscription.trial_start') 33 | trial_end = serializers.DateTimeField(source='subscription.trial_end') 34 | ended_at = serializers.DateTimeField(source='subscription.ended_at') 35 | cancel_at = serializers.DateTimeField(source='subscription.cancel_at') 36 | cancel_at_period_end = serializers.BooleanField(source='subscription.cancel_at_period_end') 37 | 38 | def get_feature_ids(self, obj): 39 | return [{"feature_id": link.feature.feature_id, "feature_desc": link.feature.description} for link in 40 | obj.price.product.linked_features.all().prefetch_related('feature')] 41 | 42 | def get_subscription_expires_at(self, obj): 43 | return obj.subscription.period_end or \ 44 | obj.subscription.cancel_at or \ 45 | obj.subscription.trial_end or \ 46 | obj.subscription.ended_at 47 | 48 | class Meta: 49 | model = SubscriptionItem 50 | fields = ( 51 | "product_id", "product_name", "product_description", "price_id", "price_nickname", "price", "freq", 52 | "subscription_status", "period_start", "period_end", 53 | "trial_start", "trial_end", "ended_at", "cancel_at", "cancel_at_period_end", "services") 54 | 55 | 56 | class ProductSerializer(serializers.ModelSerializer): 57 | class Meta: 58 | model = Product 59 | fields = "__all__" 60 | 61 | 62 | class PriceSerializer(serializers.ModelSerializer): 63 | product_id = serializers.CharField(source="product.product_id") 64 | name = serializers.CharField(source="product.name") 65 | avail = serializers.BooleanField(source="active") 66 | services = serializers.SerializerMethodField(method_name='get_feature_ids') 67 | 68 | def get_feature_ids(self, obj): 69 | return [{"feature_id": prod_feature.feature.feature_id, "feature_desc": prod_feature.feature.description} for 70 | prod_feature in 71 | obj.product.linked_features.all().prefetch_related("feature")] 72 | 73 | class Meta: 74 | model = Price 75 | fields = ("price_id", "product_id", "name", "price", "freq", "avail", "services", "currency") 76 | 77 | 78 | class CheckoutRequestSerializer(serializers.Serializer): 79 | """Handles request data to create a Stripe checkout session.""" 80 | price_id = serializers.CharField() 81 | owner = serializers.HiddenField(default=serializers.CurrentUserDefault()) 82 | 83 | def validate(self, attrs): 84 | stripe_user = get_or_create_stripe_user(user_id=self.context['request'].user.id) 85 | try: 86 | checkout_session = stripe_api_create_checkout_session( 87 | customer_id=stripe_user.customer_id, 88 | price_id=attrs['price_id'], 89 | trial_end='auto' if stripe_user.subscription_items.count() == 0 else None 90 | ) 91 | attrs['session_id'] = checkout_session['id'] 92 | except StripeError as e: 93 | raise ValidationError(e.error) 94 | return attrs 95 | 96 | def update(self, instance, validated_data): 97 | pass 98 | 99 | def create(self, validated_data): 100 | pass 101 | -------------------------------------------------------------------------------- /drf_stripe/settings.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.test.signals import setting_changed 3 | 4 | USER_SETTINGS = getattr(settings, "DRF_STRIPE", None) 5 | 6 | DEFAULTS = { 7 | "STRIPE_API_SECRET": "my_stripe_api_key", 8 | "STRIPE_WEBHOOK_SECRET": "my_stripe_webhook_key", 9 | "FRONT_END_BASE_URL": "http://localhost:3000", 10 | "NEW_USER_FREE_TRIAL_DAYS": None, 11 | "CHECKOUT_SUCCESS_URL_PATH": "payment", 12 | "CHECKOUT_CANCEL_URL_PATH": "manage-subscription", 13 | "DEFAULT_PAYMENT_METHOD_TYPES": ["card"], 14 | "DEFAULT_CHECKOUT_MODE": "subscription", 15 | "DEFAULT_DISCOUNTS": None, 16 | "ALLOW_PROMOTION_CODES": True, 17 | "DJANGO_USER_MODEL": None, 18 | "DJANGO_USER_EMAIL_FIELD": "email", # used to match Stripe customer email 19 | "USER_CREATE_DEFAULTS_ATTRIBUTE_MAP": { # attributes to copy from Stripe customer when creating new Django user 20 | "username": "email" 21 | } 22 | } 23 | 24 | 25 | class DrfStripeSettings: 26 | def __init__(self, user_settings=None, defaults=None): 27 | self._user_settings = user_settings or {} 28 | self.defaults = defaults or DEFAULTS 29 | self._cached_attrs = set() 30 | 31 | @property 32 | def user_settings(self): 33 | if not hasattr(self, "_user_settings"): 34 | self._user_settings = getattr(settings, "DRF_STRIPE", {}) 35 | return self._user_settings 36 | 37 | def __getattr__(self, attr): 38 | 39 | # check the setting is accepted 40 | if attr not in self.defaults: 41 | raise AttributeError(f"Invalid DRF_STRIPE setting: {attr}") 42 | 43 | # get from user settings or default value 44 | try: 45 | val = self.user_settings[attr] 46 | except KeyError: 47 | val = self.defaults[attr] 48 | 49 | self._cached_attrs.add(attr) 50 | setattr(self, attr, val) 51 | return val 52 | 53 | def reload(self): 54 | for attr in self._cached_attrs: 55 | delattr(self, attr) 56 | self._cached_attrs.clear() 57 | if hasattr(self, "_user_settings"): 58 | delattr(self, "_user_settings") 59 | 60 | 61 | drf_stripe_settings = DrfStripeSettings(USER_SETTINGS, DEFAULTS) 62 | 63 | 64 | def reload_drf_stripe_settings(*args, **kwargs): 65 | setting = kwargs["setting"] 66 | if setting == "DRF_STRIPE": 67 | drf_stripe_settings.reload() 68 | 69 | 70 | setting_changed.connect(reload_drf_stripe_settings) 71 | -------------------------------------------------------------------------------- /drf_stripe/stripe_api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oscarychen/drf-stripe-subscription/83cf9e3080f5fc9603790badc5323f90c3053402/drf_stripe/stripe_api/__init__.py -------------------------------------------------------------------------------- /drf_stripe/stripe_api/api.py: -------------------------------------------------------------------------------- 1 | import stripe 2 | 3 | from ..settings import drf_stripe_settings 4 | 5 | stripe.api_key = drf_stripe_settings.STRIPE_API_SECRET 6 | stripe.api_version = "2020-08-27" 7 | 8 | stripe_api = stripe 9 | -------------------------------------------------------------------------------- /drf_stripe/stripe_api/checkout.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta, datetime 2 | from functools import reduce 3 | from typing import overload, List, Union 4 | from urllib.parse import urljoin 5 | 6 | from drf_stripe.models import get_drf_stripe_user_model as get_user_model 7 | from django.utils import timezone 8 | 9 | from drf_stripe.stripe_api.api import stripe_api as stripe 10 | from ..settings import drf_stripe_settings 11 | 12 | 13 | @overload 14 | def stripe_api_create_checkout_session(customer_id: str, price_id: str, trial_end: datetime = None): 15 | ... 16 | 17 | 18 | @overload 19 | def stripe_api_create_checkout_session(user_instance, price_id: str, trial_end: datetime = None): 20 | ... 21 | 22 | 23 | def stripe_api_create_checkout_session(**kwargs): 24 | """ 25 | create a Stripe checkout session to start a subscription for user. 26 | You must provide either customer_id and price_id; 27 | or user_instance and price_id. 28 | Optionally provide a trial_end. 29 | 30 | :key user_instance: Django User instance. 31 | :key customer_id: Stripe customer id. 32 | :key str price_id: Stripe price id. 33 | :key int quantity: Defaults to 1. 34 | :key datetime trial_end: start the subscription with a trial. 35 | :key list line_items: Used when multiple price + quantity params need to be used. Defaults to None. 36 | If specified, supersedes price_id and quantity arguments. 37 | """ 38 | 39 | user_instance = kwargs.get("user_instance") 40 | customer_id = kwargs.get("customer_id") 41 | 42 | if user_instance and isinstance(user_instance, get_user_model()): 43 | return _stripe_api_create_checkout_session_for_user(**kwargs) 44 | elif customer_id and isinstance(customer_id, str): 45 | return _stripe_api_create_checkout_session_for_customer(**kwargs) 46 | else: 47 | raise TypeError("Unknown keyword arguments.") 48 | 49 | 50 | def _stripe_api_create_checkout_session_for_customer(customer_id: str, **kwargs): 51 | """ 52 | create a Stripe checkout session to start a subscription for user. 53 | 54 | :param customer_id: Stripe customer id. 55 | :param str price_id: Stripe price id. 56 | :param int quantity: Defaults to 1. 57 | :param datetime trial_end: start the subscription with a trial. 58 | :param list line_items: Used when multiple price + quantity params need to be used. Defaults to None. 59 | If specified, supersedes price_id and quantity arguments. 60 | """ 61 | stripe_checkout_params = _make_stripe_checkout_params(customer_id, **kwargs) 62 | 63 | return stripe.checkout.Session.create(**stripe_checkout_params) 64 | 65 | 66 | def _stripe_api_create_checkout_session_for_user(user_instance, **kwargs): 67 | """ 68 | create a Stripe checkout session to start a subscription for user. 69 | 70 | :param user_instance: Django User instance. 71 | :param str price_id: Stripe price id. 72 | :param bool trial_end: trial_end 73 | """ 74 | 75 | return _stripe_api_create_checkout_session_for_customer( 76 | customer_id=user_instance.stripe_user.customer_id, 77 | **kwargs 78 | ) 79 | 80 | 81 | def _make_stripe_checkout_params( 82 | customer_id: str, price_id: str = None, quantity: int = 1, line_items: List[dict] = None, 83 | trial_end: Union[str, datetime, None] = 'auto', discounts: List[dict] = None, 84 | payment_method_types: List[str] = None, checkout_mode: str = drf_stripe_settings.DEFAULT_CHECKOUT_MODE 85 | ): 86 | if price_id is None and line_items is None: 87 | raise ValueError("Invalid arguments: must provide either a 'price_id' or 'line_items'.") 88 | elif price_id is not None and line_items is not None: 89 | raise ValueError("Invalid arguments: 'price_id' and 'line_items' should be used at the same time.") 90 | 91 | if price_id is not None: 92 | line_items = [{'price': price_id, 'quantity': quantity}] 93 | 94 | if payment_method_types is None: 95 | payment_method_types = drf_stripe_settings.DEFAULT_PAYMENT_METHOD_TYPES 96 | 97 | success_url = reduce(urljoin, (drf_stripe_settings.FRONT_END_BASE_URL, 98 | drf_stripe_settings.CHECKOUT_SUCCESS_URL_PATH, 99 | "?session={CHECKOUT_SESSION_ID}")) 100 | 101 | cancel_url = reduce(urljoin, (drf_stripe_settings.FRONT_END_BASE_URL, 102 | drf_stripe_settings.CHECKOUT_CANCEL_URL_PATH)) 103 | 104 | params = { 105 | "customer": customer_id, 106 | "success_url": success_url, 107 | "cancel_url": cancel_url, 108 | "payment_method_types": payment_method_types, 109 | "mode": checkout_mode, 110 | "line_items": line_items, 111 | "subscription_data": { 112 | "trial_end": _make_trial_end_timestamp(trial_end=trial_end) 113 | } 114 | } 115 | 116 | allow_promotion_codes = drf_stripe_settings.ALLOW_PROMOTION_CODES 117 | 118 | if allow_promotion_codes: 119 | params.update({"allow_promotion_codes": allow_promotion_codes}) 120 | else: 121 | params.update({"discounts": discounts if discounts else drf_stripe_settings.DEFAULT_DISCOUNTS}) 122 | 123 | return params 124 | 125 | 126 | def _make_trial_end_timestamp(trial_end: Union[str, None, datetime] = 'auto'): 127 | """ 128 | Returns a new trial_end time to be used for setting up new Stripe Subscription. 129 | Stripe requires new Subscription trial_end to be at least 48 hours in the future. 130 | 131 | :param trial_end: Explicitly set trial end datetime. 132 | Defaults to 'auto', which will return a calculated trial_end based on NEW_USER_FREE_TRIAL_DAYS setting. 133 | If set to less than 48 hours from now, will return the minimum required trial_end acceptable to Stripe API. 134 | If set to None, no trial, returns None. 135 | """ 136 | if trial_end is None: 137 | return 138 | 139 | if trial_end == 'auto': 140 | if drf_stripe_settings.NEW_USER_FREE_TRIAL_DAYS is None: 141 | return 142 | else: 143 | trial_end = timezone.now() + timezone.timedelta(days=drf_stripe_settings.NEW_USER_FREE_TRIAL_DAYS + 1) 144 | 145 | min_trial_end = timezone.now() + timedelta(hours=49) 146 | if trial_end < min_trial_end: 147 | trial_end = min_trial_end 148 | 149 | return int(trial_end.replace(microsecond=0).timestamp()) 150 | -------------------------------------------------------------------------------- /drf_stripe/stripe_api/customer_portal.py: -------------------------------------------------------------------------------- 1 | from .api import stripe_api as stripe 2 | from .customers import get_or_create_stripe_user 3 | from ..settings import drf_stripe_settings 4 | 5 | 6 | def stripe_api_create_billing_portal_session(user_id): 7 | """ 8 | Creates a Stripe Customer Portal Session. 9 | 10 | :param str user_id: Django User id 11 | """ 12 | stripe_user = get_or_create_stripe_user(user_id=user_id) 13 | 14 | session = stripe.billing_portal.Session.create( 15 | customer=stripe_user.customer_id, 16 | return_url=f"{drf_stripe_settings.FRONT_END_BASE_URL}/manage-subscription/" 17 | ) 18 | 19 | return session 20 | -------------------------------------------------------------------------------- /drf_stripe/stripe_api/customers.py: -------------------------------------------------------------------------------- 1 | from typing import overload 2 | 3 | from drf_stripe.models import get_drf_stripe_user_model as get_user_model 4 | from django.core.exceptions import ObjectDoesNotExist 5 | from django.db.transaction import atomic 6 | 7 | from drf_stripe.models import StripeUser 8 | from drf_stripe.stripe_api.api import stripe_api as stripe 9 | from drf_stripe.stripe_models.customer import StripeCustomers, StripeCustomer 10 | from ..settings import drf_stripe_settings 11 | 12 | 13 | class CreatingNewUsersDisabledError(Exception): 14 | pass 15 | 16 | 17 | @overload 18 | def get_or_create_stripe_user(user_instance) -> StripeUser: 19 | ... 20 | 21 | 22 | @overload 23 | def get_or_create_stripe_user(user_id, user_email) -> StripeUser: 24 | ... 25 | 26 | 27 | @overload 28 | def get_or_create_stripe_user(user_id) -> StripeUser: 29 | ... 30 | 31 | 32 | @atomic() 33 | def get_or_create_stripe_user(**kwargs) -> StripeUser: 34 | """ 35 | Get or create a StripeUser given a User instance, or given user id and user email. 36 | 37 | :key user_instance: Django user instance. 38 | :key str user_id: Django User id. 39 | :key str user_email: user email address. 40 | :key str customer_id: Stripe customer id. 41 | """ 42 | user_instance = kwargs.get("user_instance") 43 | user_id = kwargs.get("user_id") 44 | user_email = kwargs.get("user_email") 45 | customer_id = kwargs.get("customer_id") 46 | 47 | if user_instance and isinstance(user_instance, get_user_model()): 48 | return _get_or_create_stripe_user_from_user_instance(user_instance) 49 | elif user_id and user_email and isinstance(user_id, str): 50 | return _get_or_create_stripe_user_from_user_id_email(user_id, user_email) 51 | elif user_id is not None: 52 | return _get_or_create_stripe_user_from_user_id(user_id) 53 | elif customer_id is not None: 54 | return _get_or_create_stripe_user_from_customer_id(customer_id) 55 | else: 56 | raise TypeError("Unknown keyword arguments!") 57 | 58 | 59 | def _get_or_create_stripe_user_from_user_instance(user_instance): 60 | """ 61 | Returns a StripeUser instance given a Django User instance. 62 | 63 | :param user_instance: Django User instance. 64 | """ 65 | return _get_or_create_stripe_user_from_user_id_email(user_instance.id, user_instance.email) 66 | 67 | 68 | def _get_or_create_stripe_user_from_user_id(user_id): 69 | """ 70 | Returns a StripeUser instance given user_id. 71 | 72 | :param str user_id: user id 73 | """ 74 | user = get_user_model().objects.get(id=user_id) 75 | 76 | return _get_or_create_stripe_user_from_user_id_email(user.id, user.email) 77 | 78 | 79 | def _get_or_create_stripe_user_from_customer_id(customer_id): 80 | """ 81 | Returns a StripeUser instance given customer_id 82 | 83 | If there is no Django user connected to a StripeUser with the given customer_id then 84 | Stripe's customer API is called to get the customer's details (e.g. email address). 85 | Then if a Django user exists for that email address a StripeUser record will be created. 86 | If a Django user does not exist for that email address and USER_CREATE_DEFAULTS_ATTRIBUTE_MAP 87 | is set then a Django user will be created along with a StripeUser record. If 88 | USER_CREATE_DEFAULTS_ATTRIBUTE_MAP is not set then a CreatingNewUsersDisabledError will be raised. 89 | 90 | :param str customer_id: Stripe customer id 91 | """ 92 | 93 | try: 94 | user = get_user_model().objects.get(stripe_user__customer_id=customer_id) 95 | 96 | except ObjectDoesNotExist: 97 | customer_response = stripe.Customer.retrieve(customer_id) 98 | customer = StripeCustomer(**customer_response) 99 | user, created = _get_or_create_django_user_if_configured(customer) 100 | if created: 101 | print(f"Created new User with customer_id {customer_id}") 102 | 103 | return _get_or_create_stripe_user_from_user_id_email(user.id, user.email, customer_id) 104 | 105 | 106 | def _get_or_create_django_user_if_configured(customer: StripeCustomer): 107 | """ 108 | If a Django user exists for the customer's email address it will be returned. 109 | If a Django user does not exist for the customer's email address and USER_CREATE_DEFAULTS_ATTRIBUTE_MAP 110 | is set then a Django user will be created and returned. 111 | If USER_CREATE_DEFAULTS_ATTRIBUTE_MAP is not set then a CreatingNewUsersDisabledError will be raised. 112 | 113 | :param customer: Stripe customer record 114 | """ 115 | 116 | django_user_query_filters = {drf_stripe_settings.DJANGO_USER_EMAIL_FIELD: customer.email} 117 | django_user = get_user_model().objects.filter( 118 | **django_user_query_filters 119 | ).first() 120 | 121 | if django_user: 122 | return django_user, False 123 | 124 | if not drf_stripe_settings.USER_CREATE_DEFAULTS_ATTRIBUTE_MAP: 125 | raise CreatingNewUsersDisabledError(f"No Django user exists with Stripe customer id '{customer.id}'s email and USER_CREATE_DEFAULTS_ATTRIBUTE_MAP is not set so a Django user cannot be created.") 126 | 127 | defaults = {k: getattr(customer, v) for k, v in 128 | drf_stripe_settings.USER_CREATE_DEFAULTS_ATTRIBUTE_MAP.items()} 129 | defaults[drf_stripe_settings.DJANGO_USER_EMAIL_FIELD] = customer.email 130 | django_user = get_user_model().objects.create( 131 | **defaults 132 | ) 133 | return django_user, True 134 | 135 | 136 | def get_or_create_stripe_user_from_customer(customer: StripeCustomer) -> StripeUser: 137 | """ 138 | Returns a StripeUser instance given customer, creating records if required. 139 | 140 | If a Django User record does not exist for the customer's email address and USER_CREATE_DEFAULTS_ATTRIBUTE_MAP is set 141 | then a new Django User record will be created with the email address and other values according to the USER_CREATE_DEFAULTS_ATTRIBUTE_MAP. 142 | If a Django User record does not exist for the customer's email address and USER_CREATE_DEFAULTS_ATTRIBUTE_MAP is not set then a CreatingNewUsersDisabledError will be thrown. 143 | 144 | :param customer: Stripe customer record 145 | """ 146 | 147 | try: 148 | return StripeUser.objects.get(customer_id=customer.id) 149 | except ObjectDoesNotExist: 150 | 151 | django_user_query_filters = {drf_stripe_settings.DJANGO_USER_EMAIL_FIELD: customer.email} 152 | 153 | django_user = get_user_model().objects.filter( 154 | **django_user_query_filters 155 | ).first() 156 | 157 | if not django_user: 158 | if not drf_stripe_settings.USER_CREATE_DEFAULTS_ATTRIBUTE_MAP: 159 | raise CreatingNewUsersDisabledError(f"No Django user exists with Stripe customer id '{customer.id}'s email and USER_CREATE_DEFAULTS_ATTRIBUTE_MAP is not set so a Django user cannot be created.") 160 | 161 | defaults = {k: getattr(customer, v) for k, v in 162 | drf_stripe_settings.USER_CREATE_DEFAULTS_ATTRIBUTE_MAP.items()} 163 | defaults[drf_stripe_settings.DJANGO_USER_EMAIL_FIELD] = customer.email 164 | django_user = get_user_model().objects.create( 165 | **defaults 166 | ) 167 | 168 | print(f"Created new Django User with email address for Stripe customer_id {customer.id}") 169 | 170 | stripe_user, stripe_user_created = StripeUser.objects.get_or_create(user_id=django_user.id, defaults={'customer_id': customer.id}) 171 | if not stripe_user_created and stripe_user.customer_id: 172 | # there's an existing StripeUser record for the Django User with the given customer's email address, but it already has a different customer_id. 173 | # (if the existing customer_id matched this one then this function would have already returned) 174 | # As there is a OneToOne relationship between DjangoUser and StripeUser we cannot create another record here, and we shouldn't assume it is 175 | # safe to replace the reference to the existing Stripe Customer. So raise an error. 176 | raise ValueError(f"A StripeUser record already exists for Django user id '{django_user.id}' which references a different customer id - called with customer id '{customer.id}', existing db customer id: '{stripe_user.customer_id}'") 177 | 178 | return stripe_user 179 | 180 | 181 | def _get_or_create_stripe_user_from_user_id_email(user_id, user_email: str, customer_id: str = None): 182 | """ 183 | Return a StripeUser instance given user_id and user_email. 184 | 185 | :param user_id: user id 186 | :param str user_email: user email address 187 | """ 188 | stripe_user, created = StripeUser.objects.get_or_create(user_id=user_id, customer_id=customer_id) 189 | 190 | if created and not customer_id: 191 | customer = _stripe_api_get_or_create_customer_from_email(user_email) 192 | stripe_user.customer_id = customer.id 193 | stripe_user.save() 194 | 195 | return stripe_user 196 | 197 | 198 | def _stripe_api_get_or_create_customer_from_email(user_email: str): 199 | """ 200 | Get or create a Stripe customer by email address. 201 | Stripe allows creation of multiple customers with the same email address, therefore it is important that you use 202 | this method to create or retrieve a Stripe Customer instead of creating one by calling the Stripe API directly. 203 | 204 | :param str user_email: user email address 205 | """ 206 | customers_response = stripe.Customer.list(email=user_email) 207 | stripe_customers = StripeCustomers(**customers_response).data 208 | 209 | if len(stripe_customers) > 0: 210 | customer = stripe_customers.pop() 211 | else: 212 | customer = stripe.Customer.create(email=user_email) 213 | 214 | return customer 215 | 216 | 217 | @atomic 218 | def stripe_api_update_customers(limit=100, starting_after=None, test_data=None): 219 | """ 220 | Retrieve list of Stripe customer objects, create StripeUser instances and optionally Django User. 221 | If a Django user does not exist a Django User will be created if setting USER_CREATE_DEFAULTS_ATTRIBUTE_MAP is set, 222 | otherwise the Customer will be skipped. 223 | 224 | Called from management command. 225 | 226 | :param int limit: Limit the number of customers to retrieve 227 | :param str starting_after: Stripe Customer id to start retrieval 228 | :param test_data: Stripe.Customer.list API response, used for testing 229 | """ 230 | 231 | if limit < 0 or limit > 100: 232 | raise ValueError("Argument limit should be a positive integer no greater than 100.") 233 | 234 | if test_data is None: 235 | customers_response = stripe.Customer.list(limit=limit, starting_after=starting_after) 236 | else: 237 | customers_response = test_data 238 | 239 | stripe_customers = StripeCustomers(**customers_response).data 240 | 241 | user_creation_count = 0 242 | stripe_user_creation_count = 0 243 | 244 | for customer in stripe_customers: 245 | # Stripe customer can have null as email 246 | if customer.email is not None: 247 | query_filters = {drf_stripe_settings.DJANGO_USER_EMAIL_FIELD: customer.email} 248 | if drf_stripe_settings.USER_CREATE_DEFAULTS_ATTRIBUTE_MAP: 249 | defaults = {k: getattr(customer, v) for k, v in 250 | drf_stripe_settings.USER_CREATE_DEFAULTS_ATTRIBUTE_MAP.items()} 251 | user, user_created = get_user_model().objects.get_or_create( 252 | **query_filters, 253 | defaults=defaults 254 | ) 255 | else: 256 | user_created = False 257 | user = get_user_model().objects.filter( 258 | **query_filters 259 | ).first() 260 | 261 | if user: 262 | stripe_user, stripe_user_created = StripeUser.objects.get_or_create(user=user, 263 | defaults={"customer_id": customer.id}) 264 | print(f"Updated Stripe Customer {customer.id}") 265 | 266 | if user_created is True: 267 | user_creation_count += 1 268 | if stripe_user_created is True: 269 | stripe_user_creation_count += 1 270 | else: 271 | print(f"Could not find Stripe Customer id '{customer.id}' in user model '{get_user_model()}' with '{drf_stripe_settings.DJANGO_USER_EMAIL_FIELD}' of '{customer.email}', USER_CREATE_DEFAULTS_ATTRIBUTE_MAP is not set so skipping Customer.") 272 | 273 | print(f"{user_creation_count} user(s) created, {stripe_user_creation_count} user(s) linked to Stripe customers.") 274 | -------------------------------------------------------------------------------- /drf_stripe/stripe_api/products.py: -------------------------------------------------------------------------------- 1 | from django.db.models import Q 2 | from django.db.transaction import atomic 3 | 4 | from drf_stripe.models import Product, Price, Feature, ProductFeature 5 | from .api import stripe_api as stripe 6 | from ..stripe_models.price import StripePrices 7 | from ..stripe_models.product import StripeProducts 8 | 9 | 10 | @atomic() 11 | def stripe_api_update_products_prices(**kwargs): 12 | """ 13 | Fetches list of Products and Price from Stripe, updates database. 14 | :key dict test_products: mock event data for testing 15 | :key dict test_prices: mock event data for testing 16 | """ 17 | _stripe_api_fetch_update_products(**kwargs) 18 | _stripe_api_fetch_update_prices(**kwargs) 19 | 20 | 21 | def _stripe_api_fetch_update_products(test_products=None, **kwargs): 22 | """ 23 | Fetch list of Stripe Products and updates database. 24 | 25 | :param dict test_products: Response from calling Stripe API: stripe.Product.list(). Used for testing. 26 | """ 27 | if test_products is None: 28 | products_data = stripe.Product.list(limit=100) 29 | else: 30 | products_data = test_products 31 | 32 | products = StripeProducts(**products_data).data 33 | 34 | creation_count = 0 35 | for product in products: 36 | product_obj, created = Product.objects.update_or_create( 37 | product_id=product.id, 38 | defaults={ 39 | "active": product.active, 40 | "description": product.description, 41 | "name": product.name 42 | } 43 | ) 44 | create_update_product_features(product) 45 | if created is True: 46 | creation_count += 1 47 | 48 | print(f"Created {creation_count} new Products") 49 | 50 | 51 | def _stripe_api_fetch_update_prices(test_prices=None, **kwargs): 52 | """ 53 | Fetch list of Stripe Prices and updates database. 54 | 55 | :param dict test_prices: Optional, response from calling Stripe API: stripe.Price.list(). Used for testing. 56 | """ 57 | if test_prices is None: 58 | prices_data = stripe.Price.list(limit=100) 59 | else: 60 | prices_data = test_prices 61 | 62 | prices = StripePrices(**prices_data).data 63 | 64 | creation_count = 0 65 | for price in prices: 66 | price_obj, created = Price.objects.update_or_create( 67 | price_id=price.id, 68 | defaults={ 69 | "product_id": price.product, 70 | "nickname": price.nickname, 71 | "price": price.unit_amount, 72 | "freq": get_freq_from_stripe_price(price), 73 | "active": price.active, 74 | "currency": price.currency 75 | } 76 | ) 77 | if created is True: 78 | creation_count += 1 79 | 80 | print(f"Created {creation_count} new Prices") 81 | 82 | 83 | def get_freq_from_stripe_price(price_data): 84 | """Get 'freq' string from Stripe price data""" 85 | if price_data.recurring: 86 | return f"{price_data.recurring.interval}_{price_data.recurring.interval_count}" 87 | 88 | 89 | @atomic 90 | def create_update_product_features(product_data): 91 | """ 92 | Create/update Feature instances associated with a Product given product data. 93 | The features are specified in Stripe Product object metadata.features as space delimited strings. 94 | See https://stripe.com/docs/api/products/object#product_object-metadata 95 | """ 96 | if hasattr(product_data, "metadata") and \ 97 | hasattr(product_data.metadata, "features") and \ 98 | product_data.metadata.features: 99 | features = product_data.metadata.features.split(" ") 100 | 101 | ProductFeature.objects.filter(Q(product_id=product_data.id) & ~Q(feature_id__in=features)).delete() 102 | 103 | for feature_id in features: 104 | feature_id = feature_id.strip() 105 | feature, created_new_feature = Feature.objects.get_or_create( 106 | feature_id=feature_id, 107 | defaults={"description": feature_id} 108 | ) 109 | ProductFeature.objects.get_or_create(product_id=product_data.id, feature=feature) 110 | 111 | if created_new_feature: 112 | print( 113 | f"Created new feature_id {feature_id}, please set feature description manually in database.") 114 | -------------------------------------------------------------------------------- /drf_stripe/stripe_api/subscriptions.py: -------------------------------------------------------------------------------- 1 | from itertools import chain 2 | from operator import attrgetter 3 | from typing import Literal, List 4 | 5 | from django.db.models import Q 6 | from django.db.models import QuerySet 7 | from django.db.transaction import atomic 8 | 9 | from drf_stripe.stripe_api.api import stripe_api as stripe 10 | from .customers import get_or_create_stripe_user, CreatingNewUsersDisabledError 11 | from ..models import Subscription, Price, SubscriptionItem 12 | from ..stripe_models.subscription import ACCESS_GRANTING_STATUSES, StripeSubscriptions 13 | 14 | """ 15 | status argument, see https://stripe.com/docs/api/subscriptions/list?lang=python#list_subscriptions-status 16 | """ 17 | STATUS_ARG = Literal[ 18 | "active", 19 | "past_due", 20 | "unpaid", 21 | "canceled", 22 | "incomplete", 23 | "incomplete_expired", 24 | "trialing", 25 | "all", 26 | "ended" 27 | ] 28 | 29 | 30 | @atomic 31 | def stripe_api_update_subscriptions(status: STATUS_ARG = None, limit: int = 100, starting_after: str = None, 32 | test_data=None, ignore_new_user_creation_errors = False): 33 | """ 34 | Retrieve all subscriptions. Updates database. 35 | 36 | Called from management command. 37 | 38 | :param STATUS_ARG status: subscription status to retrieve. 39 | :param int limit: number of instances to retrieve( between 0 and 100). 40 | :param str starting_after: subscription id to start retrieving. 41 | :param test_data: response data from Stripe API stripe.Subscription.list, used for testing 42 | :param ignore_new_user_creation_errors: if True, CreatingNewUsersDisabledError thrown by get_or_create_stripe_user() will be skipped 43 | """ 44 | 45 | if limit < 0 or limit > 100: 46 | raise ValueError("Argument limit should be a positive integer no greater than 100.") 47 | 48 | if test_data is None: 49 | subscriptions_response = stripe.Subscription.list(status=status, limit=limit, starting_after=starting_after) 50 | else: 51 | subscriptions_response = test_data 52 | 53 | stripe_subscriptions = StripeSubscriptions(**subscriptions_response).data 54 | 55 | creation_count = 0 56 | 57 | for subscription in stripe_subscriptions: 58 | try: 59 | stripe_user = get_or_create_stripe_user(customer_id=subscription.customer) 60 | 61 | _, created = Subscription.objects.update_or_create( 62 | subscription_id=subscription.id, 63 | defaults={ 64 | "stripe_user": stripe_user, 65 | "period_start": subscription.current_period_start, 66 | "period_end": subscription.current_period_end, 67 | "cancel_at": subscription.cancel_at, 68 | "cancel_at_period_end": subscription.cancel_at_period_end, 69 | "ended_at": subscription.ended_at, 70 | "status": subscription.status, 71 | "trial_end": subscription.trial_end, 72 | "trial_start": subscription.trial_start 73 | } 74 | ) 75 | print(f"Updated subscription {subscription.id}") 76 | _update_subscription_items(subscription.id, subscription.items.data) 77 | if created is True: 78 | creation_count += 1 79 | except CreatingNewUsersDisabledError as e: 80 | if not ignore_new_user_creation_errors: 81 | raise e 82 | else: 83 | print(f"User for customer id '{subscription.customer}' with subscription '{subscription.id}' does not exist, skipping.") 84 | 85 | print(f"Created {creation_count} new Subscriptions.") 86 | 87 | 88 | def _update_subscription_items(subscription_id, items_data): 89 | SubscriptionItem.objects.filter(subscription=subscription_id).delete() 90 | for item in items_data: 91 | _, created = SubscriptionItem.objects.update_or_create( 92 | sub_item_id=item.id, 93 | defaults={ 94 | "subscription_id": subscription_id, 95 | "price_id": item.price.id, 96 | "quantity": item.quantity 97 | } 98 | ) 99 | print(f"Updated sub item {item.id}") 100 | 101 | 102 | # def _stripe_api_update_subscription_items(subscription_id, limit=100, ending_before=None, test_data=None): 103 | # """ 104 | # param: str subscription_id: subscription id for which to retrieve subscription items 105 | # :param int limit: number of instances to retrieve( between 0 and 100). 106 | # :param str ending_before: subscription item id to retrieve before. 107 | # """ 108 | # if limit < 0 or limit > 100: 109 | # raise ValueError("Argument limit should be a positive integer no greater than 100.") 110 | # 111 | # if test_data is None: 112 | # items_response = stripe.SubscriptionItem.list(subscription=subscription_id, 113 | # limit=limit, 114 | # ending_before=ending_before) 115 | # else: 116 | # items_response = test_data 117 | # 118 | # sub_items = StripeSubscriptionItems(**items_response).data 119 | # 120 | # SubscriptionItem.objects.filter(subscription=subscription_id).delete() 121 | # for item in sub_items: 122 | # SubscriptionItem.objects.update_or_create( 123 | # sub_item_id=item.id, 124 | # defaults={ 125 | # "subscription_id": subscription_id, 126 | # "price_id": item.price.id, 127 | # "quantity": item.quantity 128 | # } 129 | # ) 130 | 131 | 132 | def list_user_subscriptions(user_id, current=True) -> QuerySet[Subscription]: 133 | """ 134 | Retrieve a set of Subscriptions associated with a given user id. 135 | 136 | :param user_id: Django User id. 137 | :param bool current: Defaults to True and retrieves only current subscriptions 138 | (excluding any cancelled, ended, unpaid subscriptions) 139 | """ 140 | q = Q(stripe_user__user_id=user_id) 141 | if current is True: 142 | q &= Q(status__in=ACCESS_GRANTING_STATUSES) 143 | 144 | return Subscription.objects.filter(q) 145 | 146 | 147 | def list_user_subscription_items(user_id, current=True) -> QuerySet[SubscriptionItem]: 148 | """ 149 | Retrieve a set of SubscriptionItems associated with user id 150 | 151 | :param user_id: Django User is. 152 | :param bool current: Defaults to True and retrieves only current subscriptions 153 | (excluding any cancelled, ended, unpaid subscriptions) 154 | """ 155 | q = Q(subscription__stripe_user__user_id=user_id) 156 | if current is True: 157 | q &= Q(subscription__status__in=ACCESS_GRANTING_STATUSES) 158 | 159 | return SubscriptionItem.objects.filter(q) 160 | 161 | 162 | def list_user_subscription_products(user_id, current=True): 163 | """ 164 | Retrieve a set of Product instances associated with a given User instance. 165 | 166 | :param user_id: Django User id. 167 | :param bool current: Defaults to True and retrieves only products associated with current subscriptions 168 | (excluding any cancelled, ended, unpaid subscription products) 169 | """ 170 | subscriptions = list_user_subscriptions(user_id, current=current) 171 | sub_items = chain.from_iterable( 172 | sub.items.all() for sub in subscriptions.all().prefetch_related("items__price__product")) 173 | products = set(item.price.product for item in sub_items) 174 | return products 175 | 176 | 177 | def list_subscribable_product_prices_to_user(user_id): 178 | """ 179 | Retrieve a set of Price instances associated with Products that the User isn't currently subscribed to. 180 | 181 | :param user_id: Django user id. 182 | """ 183 | current_products = set(map(attrgetter('product_id'), list_user_subscription_products(user_id))) 184 | prices = Price.objects.filter( 185 | Q(active=True) & 186 | Q(product__active=True) & 187 | ~Q(product__product_id__in=current_products) 188 | ) 189 | return prices 190 | 191 | 192 | def list_all_available_product_prices(expand: List = None): 193 | """Retrieve a set of all Price instances that are available to public.""" 194 | 195 | prices = Price.objects.filter(Q(active=True) & Q(product__active=True)) 196 | 197 | if expand and "feature" in expand: 198 | prices = prices.prefetch_related("product__linked_features__feature") 199 | 200 | return prices 201 | -------------------------------------------------------------------------------- /drf_stripe/stripe_models/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oscarychen/drf-stripe-subscription/83cf9e3080f5fc9603790badc5323f90c3053402/drf_stripe/stripe_models/__init__.py -------------------------------------------------------------------------------- /drf_stripe/stripe_models/currency.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class StripeCurrency(str, Enum): 5 | USD = 'usd' 6 | AED = 'aed' 7 | AFN = 'afn' 8 | ALL = 'all' 9 | AMD = 'amd' 10 | ANG = 'ang' 11 | AOA = 'aoa' 12 | ARS = 'ars' 13 | AUD = 'aud' 14 | AWG = 'awg' 15 | AZN = 'azn' 16 | BAM = 'bam' 17 | BBD = 'bbd' 18 | BDT = 'bdt' 19 | BGN = 'bgn' 20 | BIF = 'bif' 21 | BMD = 'bmd' 22 | BND = 'bnd' 23 | BOB = 'bob' 24 | BRL = 'brl' 25 | BSD = 'bsd' 26 | BWP = 'bwp' 27 | BYN = 'byn' 28 | BZD = 'bzd' 29 | CAD = 'cad' 30 | CDF = 'cdf' 31 | CHF = 'chf' 32 | CLP = 'clf' 33 | CNY = 'cny' 34 | COP = 'cop' 35 | CRC = 'crc' 36 | CVE = 'cve' 37 | CZK = 'czk' 38 | DJF = 'djf' 39 | DKK = 'dkk' 40 | DOP = 'dop' 41 | DZD = 'dzd' 42 | EGP = 'egp' 43 | ETB = 'etb' 44 | EUR = 'eur' 45 | FJD = 'fjd' 46 | FKP = 'fkp' 47 | GBP = 'gbp' 48 | GEL = 'gel' 49 | GIP = 'gip' 50 | GMD = 'gmd' 51 | GNF = 'gnf' 52 | GTQ = 'gtq' 53 | GYD = 'gyd' 54 | HKD = 'hkd' 55 | HNL = 'hnl' 56 | HRK = 'hrk' 57 | HTG = 'htg' 58 | HUF = 'huf' 59 | IDR = 'idr' 60 | ILS = 'ils' 61 | INR = 'inr' 62 | ISK = 'isk' 63 | JMD = 'jmd' 64 | JPY = 'jpy' 65 | KES = 'kes' 66 | KGS = 'kgs' 67 | KHR = 'khr' 68 | KMF = 'kmf' 69 | KRW = 'krw' 70 | KYD = 'kyd' 71 | KZT = 'kzt' 72 | LAK = 'lak' 73 | LBP = 'lbp' 74 | LKR = 'lkr' 75 | LRD = 'lrd' 76 | LSL = 'lsl' 77 | MAD = 'mad' 78 | MDL = 'mdl' 79 | MGA = 'mga' 80 | MKD = 'mkd' 81 | MMK = 'mmk' 82 | MNT = 'mnt' 83 | MOP = 'mop' 84 | MRO = 'mro' 85 | MUR = 'mur' 86 | MVR = 'mvr' 87 | MWK = 'mwk' 88 | MXN = 'mxn' 89 | MYR = 'myr' 90 | MZN = 'mzn' 91 | NAD = 'mad' 92 | NGN = 'ngn' 93 | NIO = 'nio' 94 | NOK = 'nok' 95 | NPR = 'npr' 96 | NZD = 'nzd' 97 | PAB = 'pab' 98 | PEN = 'pen' 99 | PGK = 'pgk' 100 | PHP = 'php' 101 | PKR = 'pkr' 102 | PLN = 'pln' 103 | PYG = 'pyg' 104 | QAR = 'qar' 105 | RON = 'ron' 106 | RSD = 'rsd' 107 | RUB = 'rub' 108 | RWF = 'rwf' 109 | SAR = 'sar' 110 | SBD = 'sbd' 111 | SCR = 'scr' 112 | SEK = 'sek' 113 | SGD = 'sgd' 114 | SHP = 'shp' 115 | SLL = 'sll' 116 | SOS = 'sos' 117 | SRD = 'srd' 118 | STD = 'std' 119 | SZL = 'szl' 120 | THB = 'thb' 121 | TJS = 'tjs' 122 | TOP = 'top' 123 | TRY = 'try' 124 | TTD = 'ttd' 125 | TWD = 'twd' 126 | TZS = 'tzs' 127 | UAH = 'uah' 128 | UGX = 'ugx' 129 | UYU = 'uyu' 130 | UZS = 'uzs' 131 | VND = 'vnd' 132 | VUV = 'vuv' 133 | WST = 'wst' 134 | XAF = 'xaf' 135 | XCD = 'xcd' 136 | XOF = 'xof' 137 | XPF = 'xpf' 138 | YER = 'yer' 139 | ZAR = 'zar' 140 | ZMW = 'zmw' 141 | -------------------------------------------------------------------------------- /drf_stripe/stripe_models/customer.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import Optional, Dict, Union, List 3 | 4 | from pydantic import BaseModel 5 | 6 | from .currency import StripeCurrency 7 | from .subscription import StripeSubscriptionItems 8 | 9 | 10 | class StripeCustomer(BaseModel): 11 | """Based on https://stripe.com/docs/api/customers/object""" 12 | id: str 13 | address: Optional[Dict] = None 14 | description: Optional[str] = None 15 | email: Optional[str] 16 | metadata: Optional[Dict] 17 | name: Optional[str] = None 18 | phone: Optional[str] = None 19 | shipping: Optional[Dict] = None 20 | balance: Optional[int] = None 21 | created: Optional[datetime] 22 | currency: Optional[StripeCurrency] = None 23 | default_source: Optional[Union[str, Dict]] = None 24 | delinquent: Optional[bool] 25 | discount: Optional[Dict] = None 26 | invoice_prefix: Optional[str] 27 | invoice_settings: Optional[Dict] 28 | livemode: Optional[bool] 29 | next_invoice_sequence: Optional[int] = None 30 | preferred_locales: Optional[List[str]] 31 | sources: Optional[List[Dict]] = None 32 | subscriptions: Optional[List[StripeSubscriptionItems]] = None 33 | tax: Optional[Dict] = None 34 | tax_exempt: Optional[str] 35 | tax_ids: Optional[List[Dict]] = None 36 | 37 | 38 | class StripeCustomers(BaseModel): 39 | """Based on https://stripe.com/docs/api/customers/list""" 40 | data: List[StripeCustomer] 41 | has_more: bool = None 42 | url: str = None 43 | -------------------------------------------------------------------------------- /drf_stripe/stripe_models/event.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from typing import Union, Literal, Any, Optional 3 | 4 | from pydantic import BaseModel, Field 5 | 6 | from .invoice import StripeInvoiceEventData 7 | from .price import StripePriceEventData 8 | from .product import StripeProductEventData 9 | from .subscription import StripeSubscriptionEventData 10 | 11 | 12 | class EventType(str, Enum): 13 | """See: https://stripe.com/docs/api/events/types""" 14 | 15 | CUSTOMER_UPDATED = 'customer.updated' 16 | 17 | CUSTOMER_SUBSCRIPTION_CREATED = 'customer.subscription.created' 18 | CUSTOMER_SUBSCRIPTION_UPDATED = 'customer.subscription.updated' 19 | CUSTOMER_SUBSCRIPTION_DELETED = 'customer.subscription.deleted' 20 | 21 | INVOICE_CREATED = 'invoice.created' 22 | INVOICE_FINALIZED = 'invoice.finalized' 23 | INVOICE_PAYMENT_SUCCEEDED = 'invoice.payment_succeeded' 24 | INVOICE_PAYMENT_FAILED = 'invoice.payment_failed' 25 | INVOICE_PAID = 'invoice.paid' 26 | 27 | INVOICEITEM_CREATED = 'invoiceitem.created' 28 | 29 | PRODUCT_CREATED = 'product.created' 30 | PRODUCT_UPDATED = 'product.updated' 31 | PRODUCT_DELETED = 'product.deleted' 32 | 33 | PRICE_DELETED = 'price.deleted' 34 | PRICE_UPDATED = 'price.updated' 35 | PRICE_CREATED = 'price.created' 36 | 37 | 38 | class StripeEventRequest(BaseModel): 39 | """Based on: https://stripe.com/docs/api/events/object#event_object-request""" 40 | id: str = None 41 | idempotency_key: Optional[str] = None 42 | 43 | 44 | class StripeBaseEvent(BaseModel): 45 | """ 46 | Based on https://stripe.com/docs/api/events/object 47 | This is the base event template for more specific Stripe event classes 48 | """ 49 | id: str 50 | api_version: str 51 | request: StripeEventRequest 52 | data: Any # overwrite this attribute when inheriting 53 | type: Literal[Any] # overwrite this attribute when inheriting 54 | 55 | 56 | class StripeInvoiceEvent(StripeBaseEvent): 57 | data: StripeInvoiceEventData 58 | type: Literal[ 59 | EventType.INVOICE_PAID, 60 | EventType.INVOICE_CREATED, 61 | EventType.INVOICE_PAID, 62 | EventType.INVOICE_PAYMENT_FAILED 63 | ] 64 | 65 | 66 | class StripeSubscriptionEvent(StripeBaseEvent): 67 | data: StripeSubscriptionEventData 68 | type: Literal[ 69 | EventType.CUSTOMER_SUBSCRIPTION_DELETED, 70 | EventType.CUSTOMER_SUBSCRIPTION_UPDATED, 71 | EventType.CUSTOMER_SUBSCRIPTION_CREATED 72 | ] 73 | 74 | 75 | class StripeProductEvent(StripeBaseEvent): 76 | data: StripeProductEventData 77 | type: Literal[ 78 | EventType.PRODUCT_UPDATED, 79 | EventType.PRODUCT_CREATED, 80 | EventType.PRODUCT_DELETED 81 | ] 82 | 83 | 84 | class StripePriceEvent(StripeBaseEvent): 85 | data: StripePriceEventData 86 | type: Literal[ 87 | EventType.PRICE_CREATED, 88 | EventType.PRICE_UPDATED, 89 | EventType.PRICE_DELETED 90 | ] 91 | 92 | 93 | class StripeEvent(BaseModel): 94 | # Add event classes to this attribute as they are implemented, more specific types first. 95 | # see https://pydantic-docs.helpmanual.io/usage/types/#discriminated-unions-aka-tagged-unions 96 | event: Union[ 97 | StripeSubscriptionEvent, 98 | StripeInvoiceEvent, 99 | StripeProductEvent, 100 | StripePriceEvent, 101 | StripeBaseEvent, # needed here so unimplemented event types can pass through validation 102 | ] = Field(discriminator='type') 103 | -------------------------------------------------------------------------------- /drf_stripe/stripe_models/invoice.py: -------------------------------------------------------------------------------- 1 | from typing import List, Dict, Optional 2 | 3 | from pydantic import BaseModel 4 | 5 | from .currency import StripeCurrency 6 | from .price import StripePrice 7 | 8 | 9 | class StripeInvoiceLineItem(BaseModel): 10 | """Based on https://stripe.com/docs/api/invoices/line_item""" 11 | id: str 12 | amount: int 13 | currency: StripeCurrency 14 | description: str = None 15 | metadata: Dict 16 | period: Dict 17 | price: StripePrice 18 | proration: bool 19 | quantity: int 20 | type: str 21 | discount_amounts: Optional[List[Dict]] 22 | discountable: Optional[bool] 23 | discounts: Optional[List[str]] 24 | invoice_item: Optional[str] 25 | subscription: str 26 | 27 | 28 | class StripeInvoiceLines(BaseModel): 29 | """Based on https://stripe.com/docs/api/invoices/object#invoice_object-lines""" 30 | data: List[StripeInvoiceLineItem] 31 | has_more: bool 32 | url: str 33 | 34 | 35 | class StripeInvoice(BaseModel): 36 | """Based on https://stripe.com/docs/api/invoices/object""" 37 | id: str 38 | auto_advance: Optional[bool] 39 | charge: str = None 40 | collection_method: Optional[str] 41 | currency: str 42 | customer: str 43 | description: str = None 44 | hosted_invoice_url: Optional[str] 45 | lines: Optional[StripeInvoiceLines] 46 | 47 | 48 | class StripeInvoiceEventData(BaseModel): 49 | """Based on https://stripe.com/docs/api/events/object#event_object-data""" 50 | object: StripeInvoice 51 | previous_attributes: Optional[StripeInvoice] 52 | -------------------------------------------------------------------------------- /drf_stripe/stripe_models/price.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from enum import Enum 3 | from typing import Dict, Union, List, Optional 4 | 5 | from pydantic import BaseModel 6 | 7 | from .currency import StripeCurrency 8 | from .product import StripeProduct 9 | 10 | 11 | class RecurringInterval(str, Enum): 12 | MONTH = 'month' 13 | YEAR = 'year' 14 | WEEK = 'week' 15 | DAY = 'day' 16 | 17 | 18 | class UsageType(str, Enum): 19 | METERED = 'metered' 20 | LICENSED = 'licensed' 21 | 22 | 23 | class PriceType(str, Enum): 24 | ONE_TIME = 'one_time' 25 | RECURRING = 'recurring' 26 | 27 | 28 | class StripePriceRecurring(BaseModel): 29 | aggregate_usage: Optional[str] = None 30 | interval: RecurringInterval 31 | interval_count: Optional[int] 32 | usage_type: Optional[UsageType] 33 | 34 | 35 | class StripePrice(BaseModel): 36 | """A single StripePrice, see https://stripe.com/docs/api/prices/object""" 37 | id: Optional[str] 38 | active: Optional[bool] 39 | currency: Optional[StripeCurrency] 40 | metadata: Optional[Dict] 41 | nickname: Optional[str] = None 42 | product: Optional[Union[str, StripeProduct]] 43 | recurring: Optional[StripePriceRecurring] = None 44 | type: Optional[PriceType] 45 | unit_amount: Optional[int] 46 | created: Optional[datetime] 47 | 48 | 49 | class StripePrices(BaseModel): 50 | """List of StripePrices""" 51 | url: str 52 | has_more: bool 53 | data: List[StripePrice] 54 | 55 | 56 | class StripePriceEventData(BaseModel): 57 | """Based on https://stripe.com/docs/api/prices/object""" 58 | object: StripePrice 59 | previous_attributes: Optional[StripePrice] 60 | -------------------------------------------------------------------------------- /drf_stripe/stripe_models/product.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import Dict, List, Union, Optional 3 | 4 | from pydantic import BaseModel 5 | 6 | 7 | class PackageDimension(BaseModel): 8 | height: float = None 9 | length: float = None 10 | weight: float = None 11 | width: float = None 12 | 13 | 14 | class StripeProductMetadata(BaseModel): 15 | features: Optional[str] = None 16 | 17 | 18 | class StripeProduct(BaseModel): 19 | """A single StripeProduct, see https://stripe.com/docs/api/products/object""" 20 | id: Optional[str] 21 | active: Optional[bool] 22 | description: Optional[str] = None 23 | metadata: Optional[Union[StripeProductMetadata, Dict]] 24 | name: Optional[str] = None 25 | created: Optional[datetime] 26 | images: Optional[List[str]] 27 | package_dimensions: Optional[PackageDimension] = None 28 | shippable: Optional[bool] = None 29 | statement_descriptor: Optional[str] = None 30 | tax_code: Optional[Union[str, Dict]] = None 31 | unit_label: Optional[str] = None 32 | updated: Optional[datetime] = None 33 | url: Optional[str] = None 34 | 35 | 36 | class StripeProducts(BaseModel): 37 | """List of StripeProducts""" 38 | url: str 39 | has_more: bool 40 | data: List[StripeProduct] 41 | 42 | 43 | class StripeProductEventData(BaseModel): 44 | """Based on https://stripe.com/docs/api/products/object""" 45 | object: StripeProduct 46 | previous_attributes: Optional[StripeProduct] 47 | -------------------------------------------------------------------------------- /drf_stripe/stripe_models/subscription.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from enum import Enum 3 | from typing import Any, Dict, List, Optional 4 | 5 | from pydantic import BaseModel 6 | 7 | from .price import StripePrice 8 | 9 | 10 | class StripeSubscriptionStatus(str, Enum): 11 | """See: https://stripe.com/docs/api/subscriptions/object#subscription_object-status""" 12 | ACTIVE = 'active' 13 | PAST_DUE = 'past_due' 14 | UNPAID = 'unpaid' 15 | CANCELED = 'canceled' 16 | INCOMPLETE = 'incomplete' 17 | INCOMPLETE_EXPIRED = 'incomplete_expired' 18 | TRIALING = 'trialing' 19 | ENDED = 'ended' 20 | 21 | 22 | ACCESS_GRANTING_STATUSES = ( 23 | StripeSubscriptionStatus.ACTIVE, 24 | StripeSubscriptionStatus.PAST_DUE, 25 | StripeSubscriptionStatus.TRIALING 26 | ) 27 | 28 | 29 | class StripeSubscriptionItemsDataItem(BaseModel): 30 | """Based on https://stripe.com/docs/api/subscriptions/object#subscription_object-items-data""" 31 | id: str 32 | billing_thresholds: Optional[Dict] = None 33 | created: datetime 34 | metadata: Dict 35 | price: StripePrice 36 | quantity: int 37 | subscription: str 38 | tax_rates: List 39 | 40 | 41 | class StripeSubscriptionItems(BaseModel): 42 | """Based on https://stripe.com/docs/api/subscriptions/object#subscription_object-items""" 43 | data: List[StripeSubscriptionItemsDataItem] 44 | has_more: bool = None 45 | url: str = None 46 | 47 | 48 | class StripeSubscription(BaseModel): 49 | """Based on https://stripe.com/docs/api/subscriptions/object""" 50 | id: Optional[str] 51 | cancel_at_period_end: Optional[bool] 52 | cancel_at: Optional[datetime] = None 53 | ended_at: Optional[datetime] = None 54 | trial_end: Optional[datetime] = None 55 | trial_start: Optional[datetime] = None 56 | current_period_end: Optional[datetime] 57 | current_period_start: Optional[datetime] 58 | customer: Optional[str] 59 | default_payment_method: Optional[str] = None 60 | items: Optional[StripeSubscriptionItems] 61 | latest_invoice: Optional[str] 62 | metadata: Optional[Dict] 63 | pending_setup_intent: Optional[str] = None 64 | pending_update: Any = None 65 | status: Optional[StripeSubscriptionStatus] 66 | 67 | 68 | class StripeSubscriptions(BaseModel): 69 | """Based on https://stripe.com/docs/api/subscriptions/list""" 70 | data: List[StripeSubscription] 71 | has_more: bool = None 72 | url: str = None 73 | 74 | 75 | class StripeSubscriptionEventData(BaseModel): 76 | """Based on https://stripe.com/docs/api/events/object#event_object-data""" 77 | object: StripeSubscription 78 | previous_attributes: Optional[StripeSubscription] = None 79 | -------------------------------------------------------------------------------- /drf_stripe/stripe_webhooks/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oscarychen/drf-stripe-subscription/83cf9e3080f5fc9603790badc5323f90c3053402/drf_stripe/stripe_webhooks/__init__.py -------------------------------------------------------------------------------- /drf_stripe/stripe_webhooks/customer_subscription.py: -------------------------------------------------------------------------------- 1 | from drf_stripe.models import Subscription, SubscriptionItem, StripeUser 2 | from drf_stripe.stripe_models.event import StripeSubscriptionEventData 3 | 4 | 5 | def _handle_customer_subscription_event_data(data: StripeSubscriptionEventData): 6 | subscription_id = data.object.id 7 | customer = data.object.customer 8 | period_start = data.object.current_period_start 9 | period_end = data.object.current_period_end 10 | cancel_at_period_end = data.object.cancel_at_period_end 11 | cancel_at = data.object.cancel_at 12 | ended_at = data.object.ended_at 13 | status = data.object.status 14 | trial_end = data.object.trial_end 15 | trial_start = data.object.trial_start 16 | 17 | stripe_user = StripeUser.objects.get(customer_id=customer) 18 | 19 | subscription, created = Subscription.objects.update_or_create( 20 | subscription_id=subscription_id, 21 | defaults={ 22 | "stripe_user": stripe_user, 23 | "period_start": period_start, 24 | "period_end": period_end, 25 | "cancel_at": cancel_at, 26 | "cancel_at_period_end": cancel_at_period_end, 27 | "ended_at": ended_at, 28 | "status": status, 29 | "trial_end": trial_end, 30 | "trial_start": trial_start 31 | }) 32 | 33 | subscription.items.all().delete() 34 | _create_subscription_items(data) 35 | 36 | 37 | def _create_subscription_items(data: StripeSubscriptionEventData): 38 | for item in data.object.items.data: 39 | SubscriptionItem.objects.update_or_create( 40 | sub_item_id=item.id, 41 | defaults={ 42 | "subscription_id": data.object.id, 43 | "price_id": item.price.id, 44 | "quantity": item.quantity 45 | } 46 | ) 47 | -------------------------------------------------------------------------------- /drf_stripe/stripe_webhooks/handler.py: -------------------------------------------------------------------------------- 1 | from pydantic import ValidationError 2 | from rest_framework.request import Request 3 | 4 | from drf_stripe.settings import drf_stripe_settings 5 | from drf_stripe.stripe_api.api import stripe_api as stripe 6 | from drf_stripe.stripe_models.event import EventType 7 | from drf_stripe.stripe_models.event import StripeEvent 8 | from .customer_subscription import _handle_customer_subscription_event_data 9 | from .price import _handle_price_event_data 10 | from .product import _handle_product_event_data 11 | 12 | 13 | def handle_stripe_webhook_request(request): 14 | event = _make_webhook_event_from_request(request) 15 | handle_webhook_event(event) 16 | 17 | 18 | def _make_webhook_event_from_request(request: Request): 19 | """ 20 | Given a Rest Framework request, construct a webhook event. 21 | 22 | :param event: event from Stripe Webhook, defaults to None. Used for test. 23 | """ 24 | 25 | return stripe.Webhook.construct_event( 26 | payload=request.body, 27 | sig_header=request.META['HTTP_STRIPE_SIGNATURE'], 28 | secret=drf_stripe_settings.STRIPE_WEBHOOK_SECRET) 29 | 30 | 31 | def _handle_event_type_validation_error(err: ValidationError): 32 | """ 33 | Handle Pydantic ValidationError raised when parsing StripeEvent, 34 | ignores the error if it is caused by unimplemented event.type; 35 | Otherwise, raise the error. 36 | """ 37 | event_type_error = False 38 | 39 | for error in err.errors(): 40 | error_loc = error['loc'] 41 | if error_loc[0] == 'event' and error.get('ctx', {}).get('discriminator_key', {}) == 'type': 42 | event_type_error = True 43 | break 44 | 45 | if event_type_error is False: 46 | raise err 47 | 48 | 49 | def handle_webhook_event(event): 50 | """Perform actions given Stripe Webhook event data.""" 51 | 52 | try: 53 | e = StripeEvent(event=event) 54 | except ValidationError as err: 55 | _handle_event_type_validation_error(err) 56 | return 57 | 58 | event_type = e.event.type 59 | 60 | if event_type is EventType.CUSTOMER_SUBSCRIPTION_CREATED: 61 | _handle_customer_subscription_event_data(e.event.data) 62 | 63 | elif event_type is EventType.CUSTOMER_SUBSCRIPTION_UPDATED: 64 | _handle_customer_subscription_event_data(e.event.data) 65 | 66 | elif event_type is EventType.CUSTOMER_SUBSCRIPTION_DELETED: 67 | _handle_customer_subscription_event_data(e.event.data) 68 | 69 | 70 | elif event_type is EventType.PRODUCT_CREATED: 71 | _handle_product_event_data(e.event.data) 72 | 73 | elif event_type is EventType.PRODUCT_UPDATED: 74 | _handle_product_event_data(e.event.data) 75 | 76 | elif event_type is EventType.PRODUCT_DELETED: 77 | _handle_product_event_data(e.event.data) 78 | 79 | 80 | elif event_type is EventType.PRICE_CREATED: 81 | _handle_price_event_data(e.event.data) 82 | 83 | elif event_type is EventType.PRICE_UPDATED: 84 | _handle_price_event_data(e.event.data) 85 | 86 | elif event_type is EventType.PRICE_DELETED: 87 | _handle_price_event_data(e.event.data) 88 | 89 | -------------------------------------------------------------------------------- /drf_stripe/stripe_webhooks/price.py: -------------------------------------------------------------------------------- 1 | from drf_stripe.models import Price 2 | from drf_stripe.stripe_api.products import get_freq_from_stripe_price 3 | from drf_stripe.stripe_models.price import StripePriceEventData 4 | 5 | 6 | def _handle_price_event_data(data: StripePriceEventData): 7 | price_id = data.object.id 8 | product_id = data.object.product 9 | nickname = data.object.nickname 10 | price = data.object.unit_amount 11 | active = data.object.active 12 | freq = get_freq_from_stripe_price(data.object) 13 | currency = data.object.currency 14 | 15 | price_obj, created = Price.objects.update_or_create( 16 | price_id=price_id, 17 | defaults={ 18 | "product_id": product_id, 19 | "nickname": nickname, 20 | "price": price, 21 | "active": active, 22 | "freq": freq, 23 | "currency": currency 24 | } 25 | ) 26 | -------------------------------------------------------------------------------- /drf_stripe/stripe_webhooks/product.py: -------------------------------------------------------------------------------- 1 | from drf_stripe.models import Product 2 | from drf_stripe.stripe_api.products import create_update_product_features 3 | from drf_stripe.stripe_models.product import StripeProductEventData 4 | 5 | 6 | def _handle_product_event_data(data: StripeProductEventData): 7 | product_id = data.object.id 8 | active = data.object.active 9 | description = data.object.description 10 | name = data.object.name 11 | 12 | product, created = Product.objects.update_or_create(product_id=product_id, defaults={ 13 | "active": active, 14 | "description": description, 15 | "name": name 16 | }) 17 | 18 | create_update_product_features(data.object) 19 | -------------------------------------------------------------------------------- /drf_stripe/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from drf_stripe import views 4 | 5 | urlpatterns = [ 6 | path('my-subscription/', views.Subscription.as_view()), 7 | path('my-subscription-items/', views.SubscriptionItems.as_view()), 8 | path('subscribable-product/', views.SubscribableProductPrice.as_view()), 9 | path('checkout/', views.CreateStripeCheckoutSession.as_view()), 10 | path('webhook/', views.StripeWebhook.as_view()), 11 | path('customer-portal/', views.StripeCustomerPortal.as_view()) 12 | ] 13 | -------------------------------------------------------------------------------- /drf_stripe/views.py: -------------------------------------------------------------------------------- 1 | from rest_framework import permissions, status 2 | from rest_framework.generics import ListAPIView 3 | from rest_framework.response import Response 4 | from rest_framework.views import APIView 5 | 6 | from drf_stripe.stripe_webhooks.handler import handle_stripe_webhook_request 7 | from .serializers import SubscriptionSerializer, PriceSerializer, SubscriptionItemSerializer, CheckoutRequestSerializer 8 | from .stripe_api.customer_portal import stripe_api_create_billing_portal_session 9 | from .stripe_api.subscriptions import list_user_subscriptions, list_user_subscription_items, \ 10 | list_subscribable_product_prices_to_user, list_all_available_product_prices 11 | 12 | 13 | class Subscription(ListAPIView): 14 | """Subscription of current user""" 15 | permission_classes = [permissions.IsAuthenticated] 16 | serializer_class = SubscriptionSerializer 17 | pagination_class = None 18 | 19 | def get_queryset(self): 20 | return list_user_subscriptions(self.request.user.id) 21 | 22 | 23 | class SubscriptionItems(ListAPIView): 24 | """SubscriptionItems of current user""" 25 | permission_classes = [permissions.IsAuthenticated] 26 | serializer_class = SubscriptionItemSerializer 27 | pagination_class = None 28 | 29 | def get_queryset(self): 30 | return list_user_subscription_items(self.request.user.id) 31 | 32 | 33 | class SubscribableProductPrice(ListAPIView): 34 | """ 35 | Products that can be subscribed. 36 | Depending on whether this request is made with a bearer token, 37 | Anonymous user will receive a list of product and prices available to the public. 38 | Authenticated user will receive a list of products and prices available to the user, excluding any product prices 39 | the user has already been subscribed to. 40 | """ 41 | permission_classes = [permissions.AllowAny] 42 | serializer_class = PriceSerializer 43 | pagination_class = None 44 | 45 | def get_queryset(self): 46 | if self.request.user.is_anonymous: 47 | return list_all_available_product_prices() 48 | else: 49 | return list_subscribable_product_prices_to_user(self.request.user.id) 50 | 51 | 52 | class CreateStripeCheckoutSession(APIView): 53 | """ 54 | Provides session for using Stripe hosted Checkout page. 55 | """ 56 | permission_classes = [permissions.IsAuthenticated] 57 | 58 | def post(self, request): 59 | serializer = CheckoutRequestSerializer(data=request.data, context={'request': request}) 60 | serializer.is_valid(raise_exception=True) 61 | return Response({'session_id': serializer.validated_data['session_id']}, status=status.HTTP_200_OK) 62 | 63 | 64 | class StripeWebhook(APIView): 65 | """Provides endpoint for Stripe webhooks""" 66 | permission_classes = [permissions.AllowAny] 67 | 68 | def post(self, request): 69 | handle_stripe_webhook_request(request) 70 | return Response(status=status.HTTP_200_OK) 71 | 72 | 73 | class StripeCustomerPortal(APIView): 74 | """Provides redirect URL for Stripe customer portal.""" 75 | 76 | permission_classes = [permissions.IsAuthenticated] 77 | 78 | def post(self, request): 79 | session = stripe_api_create_billing_portal_session(request.user.id) 80 | return Response({"url": session.url}, status=status.HTTP_200_OK) 81 | -------------------------------------------------------------------------------- /env_setup.sh: -------------------------------------------------------------------------------- 1 | 2 | #!/bin/bash 3 | 4 | echo "Removing existing environment/ directory..." 5 | rm -r environment 6 | 7 | echo "Creating new environment at environment/ ..." 8 | python3 -m venv environment/ 9 | 10 | if [[ "$OSTYPE" == "msys"* ]] 11 | then 12 | ENVPATH="environment/Scripts/activate" 13 | elif [[ "$OSTYPE" == "cygwin"* ]] 14 | then 15 | ENVPATH="environment/Scripts/activate" 16 | COMMENT_SYNTAX="rem" 17 | elif [[ "$OSTYPE" == "win32"* ]] 18 | then 19 | ENVPATH="environment/Scripts/activate" 20 | else 21 | ENVPATH="environment/bin/activate" 22 | fi 23 | 24 | echo "Installing python packages to environment at $ENVPATH ..." 25 | source $ENVPATH 26 | python3 -m pip install --upgrade pip 27 | python3 -m pip install -r requirements.txt 28 | deactivate 29 | 30 | echo "Creating activate.sh..." 31 | touch activate.sh 32 | echo > activate.sh 33 | echo "# Created by env_setup.sh, modify the environment variables below if needed.">>activate.sh 34 | echo "source $ENVPATH">> activate.sh 35 | echo >> activate.sh 36 | 37 | echo 'Finished setting up environment.' 38 | echo 'To activate environment, run `source activate.sh`.' 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /make_package_migrations.py: -------------------------------------------------------------------------------- 1 | #!environment/bin/python 2 | 3 | """ 4 | Use this script to create migrations for this standalone package.Remember to update references to model outside this 5 | package (such as auth user model) manually by editing the generated migrations files. See printout instruction below. 6 | """ 7 | 8 | import django 9 | from django.conf import settings 10 | from django.core.management import call_command 11 | 12 | settings.configure( 13 | DEBUG=True, 14 | INSTALLED_APPS=( 15 | 'django.contrib.contenttypes', 16 | 'django.contrib.auth', 17 | 'drf_stripe', 18 | ), 19 | ) 20 | 21 | django.setup() 22 | call_command('makemigrations', 'drf_stripe') 23 | 24 | print(''' 25 | Finished generating migrations. 26 | Check the migration file, update any reference to existing user model. Ie: 27 | 28 | (1) Instead of 29 | ``` 30 | dependencies = [ 31 | ('auth', '0012_alter_user_first_name_max_length'), 32 | ] 33 | ``` 34 | Change it to: 35 | ``` 36 | dependencies = [ 37 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 38 | ] 39 | ``` 40 | 41 | (2) In fields, instead of referring to user model as 42 | ``` 43 | 'auth.user' 44 | ``` 45 | change it to 46 | ``` 47 | settings.AUTH_USER_MODEL 48 | ``` 49 | ''') 50 | -------------------------------------------------------------------------------- /manifest.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | recursive-include docs * 3 | recursive-exclude tests * -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | django>=3.2 2 | djangorestframework>=3.10 3 | pydantic>=1.8, < 2.0 4 | stripe>=2.63 5 | tox==3.26 6 | wheel>=0.37 -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = drf-stripe-subscription 3 | version = 1.2.2 4 | description = A Django app that provides subscription features with Stripe and REST endpoints. 5 | long_description = file: README.md 6 | url = https://github.com/oscarychen/drf-stripe-subscription 7 | author = Oscar Chen 8 | author_email = quacky@duck.com 9 | license = MIT 10 | classifiers = 11 | Environment :: Web Environment 12 | Framework :: Django 13 | Framework :: Django :: 3.0 14 | Framework :: Django :: 3.1 15 | Framework :: Django :: 3.2 16 | Framework :: Django :: 4.0 17 | Framework :: Django :: 4.1 18 | Framework :: Django :: 4.2 19 | Framework :: Django :: 5.0 20 | Intended Audience :: Developers 21 | License :: OSI Approved :: BSD License 22 | Operating System :: OS Independent 23 | Programming Language :: Python 24 | Programming Language :: Python :: 3 25 | Programming Language :: Python :: 3 :: Only 26 | Programming Language :: Python :: 3.8 27 | Programming Language :: Python :: 3.9 28 | Programming Language :: Python :: 3.10 29 | Programming Language :: Python :: 3.11 30 | Programming Language :: Python :: 3.12 31 | Topic :: Internet :: WWW/HTTP 32 | Topic :: Internet :: WWW/HTTP :: Dynamic Content 33 | 34 | [options] 35 | include_package_data = true 36 | packages = find: 37 | python_requires = >=3.8 38 | install_requires = 39 | Django >= 3.0 40 | djangorestframework >= 3.0 41 | pydantic >= 1.8 42 | stripe >= 2.63 -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | setup( 4 | long_description_content_type='text/markdown', 5 | packages=find_packages(exclude=("tests",)), 6 | ) 7 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oscarychen/drf-stripe-subscription/83cf9e3080f5fc9603790badc5323f90c3053402/tests/__init__.py -------------------------------------------------------------------------------- /tests/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oscarychen/drf-stripe-subscription/83cf9e3080f5fc9603790badc5323f90c3053402/tests/api/__init__.py -------------------------------------------------------------------------------- /tests/api/test_update_customers.py: -------------------------------------------------------------------------------- 1 | from drf_stripe.models import get_drf_stripe_user_model as get_user_model 2 | 3 | from drf_stripe.models import StripeUser 4 | from drf_stripe.stripe_api.customers import stripe_api_update_customers 5 | from ..base import BaseTest 6 | 7 | from drf_stripe.settings import drf_stripe_settings 8 | from django.test import override_settings 9 | 10 | 11 | class TestCustomer(BaseTest): 12 | def setUp(self) -> None: 13 | self.setup_user_customer() 14 | self.setup_product_prices() 15 | 16 | def test_update_customers(self): 17 | """ 18 | Test retrieving list of customers from Stripe and creation of Django User and StripeUser instances. 19 | """ 20 | 21 | response = self._load_test_data("v1/api_customer_list_2_items.json") 22 | 23 | stripe_api_update_customers(test_data=response) 24 | 25 | user_1 = get_user_model().objects.get(email="tester1@example.com") 26 | stripe_user_1 = StripeUser.objects.get(user=user_1) 27 | self.assertEqual(stripe_user_1.customer_id, "cus_tester") 28 | 29 | user_2 = get_user_model().objects.get(email="tester2@example.com") 30 | stripe_user_2 = StripeUser.objects.get(user=user_2) 31 | self.assertEqual(stripe_user_2.customer_id, "cus_tester2") 32 | 33 | user_3 = get_user_model().objects.get(email="tester3@example.com") 34 | stripe_user_3 = StripeUser.objects.get(user=user_3) 35 | self.assertEqual(stripe_user_3.customer_id, "cus_tester3") 36 | 37 | def test_update_customers_without_creating_django_users(self): 38 | """ 39 | Test retrieving list of customers from Stripe when setting USER_CREATE_DEFAULTS_ATTRIBUTE_MAP is None - Django User should not be created. 40 | """ 41 | drf_stripe_copy = drf_stripe_settings.user_settings 42 | drf_stripe_copy['USER_CREATE_DEFAULTS_ATTRIBUTE_MAP'] = None 43 | with override_settings(DRF_STRIPE=drf_stripe_copy): 44 | response = self._load_test_data("v1/api_customer_list_2_items.json") 45 | 46 | stripe_api_update_customers(test_data=response) 47 | 48 | # user_1 created by setup_user_customer() should exist and have StripeUser record created 49 | user_1 = get_user_model().objects.get(email="tester1@example.com") 50 | stripe_user_1 = StripeUser.objects.get(user=user_1) 51 | self.assertEqual(stripe_user_1.customer_id, "cus_tester") 52 | 53 | # user_2 and 3 are unknown and in this test should not be created 54 | user_2 = get_user_model().objects.filter(email="tester2@example.com").first() 55 | self.assertIsNone(user_2) 56 | stripe_user_2 = StripeUser.objects.filter(customer_id="cus_tester2").first() 57 | self.assertIsNone(stripe_user_2) 58 | 59 | user_3 = get_user_model().objects.filter(email="tester3@example.com").first() 60 | self.assertIsNone(user_3) 61 | stripe_user_3 = StripeUser.objects.filter(customer_id="cus_tester3").first() 62 | self.assertIsNone(stripe_user_3) 63 | -------------------------------------------------------------------------------- /tests/api/test_update_products_prices.py: -------------------------------------------------------------------------------- 1 | from drf_stripe.models import Product, Price, Feature, ProductFeature 2 | from tests.base import BaseTest 3 | 4 | 5 | class TestProductInitialization(BaseTest): 6 | def setUp(self) -> None: 7 | self.setup_product_prices() 8 | 9 | def test_products_prices_initialization(self): 10 | """ 11 | Check products and prices have been set up properly in database. 12 | This tests drf_stripe.stripe_api.products.stripe_api_update_products_prices() 13 | which is the core of the 'update_stripe_products' management command. 14 | """ 15 | 16 | prod_abc = Product.objects.get(product_id='prod_KxgA5goLUMwnoN') 17 | prod_abd = Product.objects.get(product_id='prod_KxfXRXOd7dnLbz') 18 | 19 | price_abc1 = Price.objects.get(price_id='price_1KHkoTL14ex1CGCiV8X4cJs5') 20 | 21 | price_abd1 = Price.objects.get(price_id='price_1KHkCLL14ex1CGCipzcBdnOp') 22 | price_abd2 = Price.objects.get(price_id='price_1KHkCLL14ex1CGCieIBu8V2e') 23 | 24 | feature_a = Feature.objects.get(feature_id='A') 25 | feature_b = Feature.objects.get(feature_id='B') 26 | feature_c = Feature.objects.get(feature_id='C') 27 | feature_d = Feature.objects.get(feature_id='D') 28 | 29 | # Check product-to-feature relations 30 | ProductFeature.objects.get(product=prod_abc, feature=feature_a) 31 | ProductFeature.objects.get(product=prod_abc, feature=feature_b) 32 | ProductFeature.objects.get(product=prod_abc, feature=feature_c) 33 | ProductFeature.objects.get(product=prod_abd, feature=feature_a) 34 | ProductFeature.objects.get(product=prod_abd, feature=feature_b) 35 | ProductFeature.objects.get(product=prod_abd, feature=feature_d) 36 | self.assertEqual(len(ProductFeature.objects.filter(product=prod_abc, feature=feature_d)), 0) 37 | self.assertEqual(len(ProductFeature.objects.filter(product=prod_abd, feature=feature_c)), 0) 38 | 39 | # Check price-to-product relations 40 | self.assertEqual(price_abc1.product.product_id, prod_abc.product_id) 41 | self.assertEqual(price_abd1.product.product_id, prod_abd.product_id) 42 | self.assertEqual(price_abd2.product.product_id, prod_abd.product_id) 43 | -------------------------------------------------------------------------------- /tests/api/test_update_subscriptions.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model 2 | from drf_stripe.models import Subscription, StripeUser 3 | from drf_stripe.stripe_api.subscriptions import stripe_api_update_subscriptions 4 | from ..base import BaseTest 5 | 6 | from unittest.mock import patch 7 | from drf_stripe.settings import drf_stripe_settings 8 | from django.test import override_settings 9 | 10 | 11 | class TestSubscription(BaseTest): 12 | def setUp(self) -> None: 13 | self.setup_user_customer() 14 | self.setup_product_prices() 15 | 16 | @patch('stripe.Customer.retrieve') 17 | def test_update_subscriptions(self, mocked_retrieve_fn): 18 | """ 19 | Test retrieving list of subscription from Stripe and update database. 20 | """ 21 | 22 | response = self._load_test_data("v1/api_subscription_list.json") 23 | mocked_retrieve_fn.return_value = { 24 | "email": "tester2@example.com", 25 | "id": "cus_tester2", 26 | } 27 | 28 | stripe_api_update_subscriptions(test_data=response) 29 | 30 | subscription = Subscription.objects.get(subscription_id="sub_0001") 31 | self.assertEqual(subscription.status, "trialing") 32 | self.assertEqual(subscription.stripe_user.customer_id, "cus_tester") 33 | sub_items = subscription.items.all() 34 | self.assertEqual(len(sub_items), 1) 35 | sub_item = sub_items.first() 36 | self.assertEqual(sub_item.price.price_id, "price_1KHkCLL14ex1CGCipzcBdnOp") 37 | 38 | subscription2 = Subscription.objects.get(subscription_id="sub_0002") 39 | self.assertEqual(subscription2.status, "trialing") 40 | self.assertEqual(subscription2.stripe_user.customer_id, "cus_tester2") 41 | sub2_items = subscription2.items.all() 42 | self.assertEqual(len(sub2_items), 1) 43 | sub2_item = sub2_items.first() 44 | self.assertEqual(sub2_item.price.price_id, "price_1KHkCLL14ex1CGCipzcBdnOp") 45 | 46 | user_2 = get_user_model().objects.filter(email="tester2@example.com").first() 47 | self.assertIsNotNone(user_2) 48 | stripe_user_2 = StripeUser.objects.filter(customer_id="cus_tester2").first() 49 | self.assertIsNotNone(stripe_user_2) 50 | 51 | @patch('stripe.Customer.retrieve') 52 | def test_update_subscriptions_without_creating_django_users(self, mocked_retrieve_fn): 53 | """ 54 | Test retrieving list of subscription from Stripe and update database without creating django users if they don't already exist. 55 | """ 56 | drf_stripe_copy = drf_stripe_settings.user_settings 57 | drf_stripe_copy['USER_CREATE_DEFAULTS_ATTRIBUTE_MAP'] = None 58 | with override_settings(DRF_STRIPE=drf_stripe_copy): 59 | response = self._load_test_data("v1/api_subscription_list.json") 60 | 61 | mocked_retrieve_fn.return_value = { 62 | "email": "tester2@example.com", 63 | "id": "cus_tester2", 64 | } 65 | 66 | stripe_api_update_subscriptions(test_data=response, ignore_new_user_creation_errors=True) 67 | 68 | subscription = Subscription.objects.get(subscription_id="sub_0001") 69 | self.assertEqual(subscription.status, "trialing") 70 | self.assertEqual(subscription.stripe_user.customer_id, "cus_tester") 71 | sub_items = subscription.items.all() 72 | self.assertEqual(len(sub_items), 1) 73 | sub_item = sub_items.first() 74 | self.assertEqual(sub_item.price.price_id, "price_1KHkCLL14ex1CGCipzcBdnOp") 75 | 76 | subscription2 = Subscription.objects.filter(subscription_id="sub_0002").first() 77 | self.assertIsNone(subscription2) 78 | 79 | user_2 = get_user_model().objects.filter(email="tester2@example.com").first() 80 | self.assertIsNone(user_2) 81 | stripe_user_2 = StripeUser.objects.filter(customer_id="cus_tester2").first() 82 | self.assertIsNone(stripe_user_2) 83 | -------------------------------------------------------------------------------- /tests/base.py: -------------------------------------------------------------------------------- 1 | import json 2 | from pathlib import Path 3 | 4 | from drf_stripe.models import get_drf_stripe_user_model as get_user_model 5 | from django.test import TestCase 6 | 7 | from drf_stripe.models import StripeUser 8 | from drf_stripe.stripe_api.products import stripe_api_update_products_prices 9 | 10 | 11 | class BaseTest(TestCase): 12 | 13 | def setUp(self) -> None: 14 | pass 15 | 16 | def tearDown(self) -> None: 17 | pass 18 | 19 | def setup_product_prices(self): 20 | products = self._load_test_data("v1/api_product_list.json") 21 | prices = self._load_test_data("v1/api_price_list.json") 22 | stripe_api_update_products_prices(test_products=products, test_prices=prices) 23 | 24 | @staticmethod 25 | def setup_user_customer(): 26 | user = get_user_model().objects.create(username="tester", email="tester1@example.com", password="12345") 27 | stripe_user = StripeUser.objects.create(user_id=user.id, customer_id="cus_tester") 28 | return user, stripe_user 29 | 30 | @staticmethod 31 | def _load_test_data(file_name): 32 | p = Path("tests/mock_responses") / file_name 33 | with open(p, 'r', encoding='utf-8') as f: 34 | data = json.load(f) 35 | 36 | return data 37 | 38 | @staticmethod 39 | def _print(v): 40 | print("$$$$$$$ DEBUG $$$$$") 41 | print(v) 42 | assert False 43 | -------------------------------------------------------------------------------- /tests/mock_responses/2020-08-27/webhook_price_created.json: -------------------------------------------------------------------------------- 1 | { 2 | "api_version": "2020-08-27", 3 | "created": 1642145266, 4 | "data": { 5 | "object": { 6 | "active": true, 7 | "billing_scheme": "per_unit", 8 | "created": 1642145265, 9 | "currency": "usd", 10 | "id": "price_1KHkCLL14ex1CGCipzcBdnOp", 11 | "livemode": false, 12 | "lookup_key": null, 13 | "metadata": {}, 14 | "nickname": "Monthly subscription", 15 | "object": "price", 16 | "product": "prod_KxfXRXOd7dnLbz", 17 | "recurring": { 18 | "aggregate_usage": null, 19 | "interval": "month", 20 | "interval_count": 1, 21 | "trial_period_days": null, 22 | "usage_type": "licensed" 23 | }, 24 | "tax_behavior": "exclusive", 25 | "tiers_mode": null, 26 | "transform_quantity": null, 27 | "type": "recurring", 28 | "unit_amount": 100, 29 | "unit_amount_decimal": "100" 30 | } 31 | }, 32 | "id": "evt_1KHkCML14ex1CGCiabjNipG2", 33 | "livemode": false, 34 | "object": "event", 35 | "pending_webhooks": 2, 36 | "request": { 37 | "id": "req_v5bYehVzM06bze", 38 | "idempotency_key": "" 39 | }, 40 | "type": "price.created" 41 | } -------------------------------------------------------------------------------- /tests/mock_responses/2020-08-27/webhook_price_updated.json: -------------------------------------------------------------------------------- 1 | { 2 | "api_version": "2020-08-27", 3 | "created": 1642145430, 4 | "data": { 5 | "object": { 6 | "active": true, 7 | "billing_scheme": "per_unit", 8 | "created": 1642145265, 9 | "currency": "usd", 10 | "id": "price_1KHkCLL14ex1CGCipzcBdnOp", 11 | "livemode": false, 12 | "lookup_key": null, 13 | "metadata": {}, 14 | "nickname": "Weekly subscription", 15 | "object": "price", 16 | "product": "prod_KxfXRXOd7dnLbz", 17 | "recurring": { 18 | "aggregate_usage": null, 19 | "interval": "week", 20 | "interval_count": 1, 21 | "trial_period_days": null, 22 | "usage_type": "licensed" 23 | }, 24 | "tax_behavior": "exclusive", 25 | "tiers_mode": null, 26 | "transform_quantity": null, 27 | "type": "recurring", 28 | "unit_amount": 50, 29 | "unit_amount_decimal": "50" 30 | }, 31 | "previous_attributes": { 32 | "nickname": "Monthly subscription", 33 | "recurring": { 34 | "interval": "month" 35 | }, 36 | "unit_amount": 100, 37 | "unit_amount_decimal": "100" 38 | } 39 | }, 40 | "id": "evt_1KHkF0L14ex1CGCiSl66XyN5", 41 | "livemode": false, 42 | "object": "event", 43 | "pending_webhooks": 2, 44 | "request": { 45 | "id": "req_Y59PNAkN15JUwT", 46 | "idempotency_key": "" 47 | }, 48 | "type": "price.updated" 49 | } -------------------------------------------------------------------------------- /tests/mock_responses/2020-08-27/webhook_price_updated_archived.json: -------------------------------------------------------------------------------- 1 | { 2 | "api_version": "2020-08-27", 3 | "created": 1642152888, 4 | "data": { 5 | "object": { 6 | "active": false, 7 | "billing_scheme": "per_unit", 8 | "created": 1642145265, 9 | "currency": "usd", 10 | "id": "price_1KHkCLL14ex1CGCieIBu8V2e", 11 | "livemode": false, 12 | "lookup_key": null, 13 | "metadata": {}, 14 | "nickname": "Year subscription", 15 | "object": "price", 16 | "product": "prod_KxfXRXOd7dnLbz", 17 | "recurring": { 18 | "aggregate_usage": null, 19 | "interval": "year", 20 | "interval_count": 1, 21 | "trial_period_days": null, 22 | "usage_type": "licensed" 23 | }, 24 | "tax_behavior": "exclusive", 25 | "tiers_mode": null, 26 | "transform_quantity": null, 27 | "type": "recurring", 28 | "unit_amount": 1000, 29 | "unit_amount_decimal": "1000" 30 | }, 31 | "previous_attributes": { 32 | "active": true 33 | } 34 | }, 35 | "id": "evt_1KHmBJL14ex1CGCit7XC2tgc", 36 | "livemode": false, 37 | "object": "event", 38 | "pending_webhooks": 2, 39 | "request": { 40 | "id": "req_vCkM5C31CoY6IR", 41 | "idempotency_key": "" 42 | }, 43 | "type": "price.updated" 44 | } -------------------------------------------------------------------------------- /tests/mock_responses/2020-08-27/webhook_product_created.json: -------------------------------------------------------------------------------- 1 | { 2 | "api_version": "2020-08-27", 3 | "created": 1642145265, 4 | "data": { 5 | "object": { 6 | "active": true, 7 | "attributes": [], 8 | "created": 1642145265, 9 | "description": "Test Product ABC", 10 | "id": "prod_KxfXRXOd7dnLbz", 11 | "images": [], 12 | "livemode": false, 13 | "metadata": { 14 | "features": "A B C" 15 | }, 16 | "name": "Test Product ABC", 17 | "object": "product", 18 | "package_dimensions": null, 19 | "shippable": null, 20 | "statement_descriptor": null, 21 | "tax_code": "txcd_10000000", 22 | "type": "service", 23 | "unit_label": null, 24 | "updated": 1642145265, 25 | "url": null 26 | } 27 | }, 28 | "id": "evt_1KHkCLL14ex1CGCibvFzwYbH", 29 | "livemode": false, 30 | "object": "event", 31 | "pending_webhooks": 2, 32 | "request": { 33 | "id": "req_7pSl5To9A3UhJZ", 34 | "idempotency_key": "" 35 | }, 36 | "type": "product.created" 37 | } -------------------------------------------------------------------------------- /tests/mock_responses/2020-08-27/webhook_product_updated.json: -------------------------------------------------------------------------------- 1 | { 2 | "api_version": "2020-08-27", 3 | "created": 1642145979, 4 | "data": { 5 | "object": { 6 | "active": true, 7 | "attributes": [], 8 | "created": 1642145265, 9 | "description": "Test Product ABD", 10 | "id": "prod_KxfXRXOd7dnLbz", 11 | "images": [], 12 | "livemode": false, 13 | "metadata": { 14 | "features": "A B D" 15 | }, 16 | "name": "Test Product ABD", 17 | "object": "product", 18 | "package_dimensions": null, 19 | "shippable": null, 20 | "statement_descriptor": null, 21 | "tax_code": "txcd_10000000", 22 | "type": "service", 23 | "unit_label": null, 24 | "updated": 1642145979, 25 | "url": null 26 | }, 27 | "previous_attributes": { 28 | "description": "Test Product ABC", 29 | "metadata": { 30 | "features": "A B C" 31 | }, 32 | "name": "Test Product ABC", 33 | "updated": 1642145429 34 | } 35 | }, 36 | "id": "evt_1KHkNrL14ex1CGCiGBC5Nztb", 37 | "livemode": false, 38 | "object": "event", 39 | "pending_webhooks": 2, 40 | "request": { 41 | "id": "req_ZqU32SHejE31zN", 42 | "idempotency_key": "" 43 | }, 44 | "type": "product.updated" 45 | } -------------------------------------------------------------------------------- /tests/mock_responses/2020-08-27/webhook_product_updated_archived.json: -------------------------------------------------------------------------------- 1 | { 2 | "api_version": "2020-08-27", 3 | "created": 1642153400, 4 | "data": { 5 | "object": { 6 | "active": false, 7 | "attributes": [], 8 | "created": 1642145265, 9 | "description": "Test Product ABD", 10 | "id": "prod_KxfXRXOd7dnLbz", 11 | "images": [], 12 | "livemode": false, 13 | "metadata": { 14 | "features": "A B D" 15 | }, 16 | "name": "Test Product ABD", 17 | "object": "product", 18 | "package_dimensions": null, 19 | "shippable": null, 20 | "statement_descriptor": null, 21 | "tax_code": "txcd_10000000", 22 | "type": "service", 23 | "unit_label": null, 24 | "updated": 1642153400, 25 | "url": null 26 | }, 27 | "previous_attributes": { 28 | "active": true, 29 | "updated": 1642145979 30 | } 31 | }, 32 | "id": "evt_1KHmJYL14ex1CGCipJEW2lSB", 33 | "livemode": false, 34 | "object": "event", 35 | "pending_webhooks": 2, 36 | "request": { 37 | "id": "req_NpzjYNIcborYci", 38 | "idempotency_key": "" 39 | }, 40 | "type": "product.updated" 41 | } -------------------------------------------------------------------------------- /tests/mock_responses/2020-08-27/webhook_subscription_created.json: -------------------------------------------------------------------------------- 1 | { 2 | "api_version": "2020-08-27", 3 | "created": 1642150471, 4 | "data": { 5 | "object": { 6 | "application_fee_percent": null, 7 | "automatic_tax": { 8 | "enabled": false 9 | }, 10 | "billing_cycle_anchor": 1642150469, 11 | "billing_thresholds": null, 12 | "cancel_at": null, 13 | "cancel_at_period_end": false, 14 | "canceled_at": null, 15 | "collection_method": "charge_automatically", 16 | "created": 1642150469, 17 | "current_period_end": 1644828869, 18 | "current_period_start": 1642150469, 19 | "customer": "cus_tester", 20 | "days_until_due": null, 21 | "default_payment_method": null, 22 | "default_source": null, 23 | "default_tax_rates": [], 24 | "discount": null, 25 | "ended_at": null, 26 | "id": "sub_1KHlYHL14ex1CGCiIBo8Xk5p", 27 | "items": { 28 | "data": [ 29 | { 30 | "billing_thresholds": null, 31 | "created": 1642150470, 32 | "id": "si_KxgwlJyHxmgJKx", 33 | "metadata": {}, 34 | "object": "subscription_item", 35 | "plan": { 36 | "active": true, 37 | "aggregate_usage": null, 38 | "amount": 100, 39 | "amount_decimal": "100", 40 | "billing_scheme": "per_unit", 41 | "created": 1642145265, 42 | "currency": "usd", 43 | "id": "price_1KHkCLL14ex1CGCipzcBdnOp", 44 | "interval": "month", 45 | "interval_count": 1, 46 | "livemode": false, 47 | "metadata": {}, 48 | "nickname": "Monthly subscription", 49 | "object": "plan", 50 | "product": "prod_KxfXRXOd7dnLbz", 51 | "tiers_mode": null, 52 | "transform_usage": null, 53 | "trial_period_days": null, 54 | "usage_type": "licensed" 55 | }, 56 | "price": { 57 | "active": true, 58 | "billing_scheme": "per_unit", 59 | "created": 1642145265, 60 | "currency": "usd", 61 | "id": "price_1KHkCLL14ex1CGCipzcBdnOp", 62 | "livemode": false, 63 | "lookup_key": null, 64 | "metadata": {}, 65 | "nickname": "Monthly subscription", 66 | "object": "price", 67 | "product": "prod_KxfXRXOd7dnLbz", 68 | "recurring": { 69 | "aggregate_usage": null, 70 | "interval": "month", 71 | "interval_count": 1, 72 | "trial_period_days": null, 73 | "usage_type": "licensed" 74 | }, 75 | "tax_behavior": "exclusive", 76 | "tiers_mode": null, 77 | "transform_quantity": null, 78 | "type": "recurring", 79 | "unit_amount": 100, 80 | "unit_amount_decimal": "100" 81 | }, 82 | "quantity": 1, 83 | "subscription": "sub_1KHlYHL14ex1CGCiIBo8Xk5p", 84 | "tax_rates": [] 85 | } 86 | ], 87 | "has_more": false, 88 | "object": "list", 89 | "total_count": 1, 90 | "url": "/v1/subscription_items?subscription=sub_1KHlYHL14ex1CGCiIBo8Xk5p" 91 | }, 92 | "latest_invoice": "in_1KHlYIL14ex1CGCiHSj2FQyU", 93 | "livemode": false, 94 | "metadata": {}, 95 | "next_pending_invoice_item_invoice": null, 96 | "object": "subscription", 97 | "pause_collection": null, 98 | "payment_settings": { 99 | "payment_method_options": null, 100 | "payment_method_types": null 101 | }, 102 | "pending_invoice_item_interval": null, 103 | "pending_setup_intent": null, 104 | "pending_update": null, 105 | "plan": { 106 | "active": true, 107 | "aggregate_usage": null, 108 | "amount": 100, 109 | "amount_decimal": "100", 110 | "billing_scheme": "per_unit", 111 | "created": 1642145265, 112 | "currency": "usd", 113 | "id": "price_1KHkCLL14ex1CGCipzcBdnOp", 114 | "interval": "month", 115 | "interval_count": 1, 116 | "livemode": false, 117 | "metadata": {}, 118 | "nickname": "Monthly subscription", 119 | "object": "plan", 120 | "product": "prod_KxfXRXOd7dnLbz", 121 | "tiers_mode": null, 122 | "transform_usage": null, 123 | "trial_period_days": null, 124 | "usage_type": "licensed" 125 | }, 126 | "quantity": 1, 127 | "schedule": null, 128 | "start_date": 1642150469, 129 | "status": "active", 130 | "transfer_data": null, 131 | "trial_end": null, 132 | "trial_start": null 133 | } 134 | }, 135 | "id": "evt_1KHlYKL14ex1CGCi10K7ohSd", 136 | "livemode": false, 137 | "object": "event", 138 | "pending_webhooks": 2, 139 | "request": { 140 | "id": "req_CVkWXJPhAypL1x", 141 | "idempotency_key": "" 142 | }, 143 | "type": "customer.subscription.created" 144 | } -------------------------------------------------------------------------------- /tests/mock_responses/2020-08-27/webhook_subscription_updated_apply_coupon.json: -------------------------------------------------------------------------------- 1 | { 2 | "api_version": "2020-08-27", 3 | "created": 1642151869, 4 | "data": { 5 | "object": { 6 | "application_fee_percent": null, 7 | "automatic_tax": { 8 | "enabled": false 9 | }, 10 | "billing_cycle_anchor": 1642150617, 11 | "billing_thresholds": null, 12 | "cancel_at": null, 13 | "cancel_at_period_end": false, 14 | "canceled_at": null, 15 | "collection_method": "charge_automatically", 16 | "created": 1642150469, 17 | "current_period_end": 1673686617, 18 | "current_period_start": 1642150617, 19 | "customer": "cus_tester", 20 | "days_until_due": null, 21 | "default_payment_method": null, 22 | "default_source": null, 23 | "default_tax_rates": [], 24 | "discount": { 25 | "checkout_session": null, 26 | "coupon": { 27 | "amount_off": null, 28 | "created": 1642151846, 29 | "currency": null, 30 | "duration": "forever", 31 | "duration_in_months": null, 32 | "id": "Ck2QXzxk", 33 | "livemode": false, 34 | "max_redemptions": 3, 35 | "metadata": {}, 36 | "name": "Test Coupon", 37 | "object": "coupon", 38 | "percent_off": 20.0, 39 | "redeem_by": null, 40 | "times_redeemed": 1, 41 | "valid": true 42 | }, 43 | "customer": "cus_tester", 44 | "end": null, 45 | "id": "di_1KHlurL14ex1CGCit6pd30js", 46 | "invoice": null, 47 | "invoice_item": null, 48 | "object": "discount", 49 | "promotion_code": null, 50 | "start": 1642151869, 51 | "subscription": "sub_1KHlYHL14ex1CGCiIBo8Xk5p" 52 | }, 53 | "ended_at": null, 54 | "id": "sub_1KHlYHL14ex1CGCiIBo8Xk5p", 55 | "items": { 56 | "data": [ 57 | { 58 | "billing_thresholds": null, 59 | "created": 1642150617, 60 | "id": "si_Kxgyikq4yxQ70f", 61 | "metadata": {}, 62 | "object": "subscription_item", 63 | "plan": { 64 | "active": true, 65 | "aggregate_usage": null, 66 | "amount": 1000, 67 | "amount_decimal": "1000", 68 | "billing_scheme": "per_unit", 69 | "created": 1642145265, 70 | "currency": "usd", 71 | "id": "price_1KHkCLL14ex1CGCieIBu8V2e", 72 | "interval": "year", 73 | "interval_count": 1, 74 | "livemode": false, 75 | "metadata": {}, 76 | "nickname": "Year subscription", 77 | "object": "plan", 78 | "product": "prod_KxfXRXOd7dnLbz", 79 | "tiers_mode": null, 80 | "transform_usage": null, 81 | "trial_period_days": null, 82 | "usage_type": "licensed" 83 | }, 84 | "price": { 85 | "active": true, 86 | "billing_scheme": "per_unit", 87 | "created": 1642145265, 88 | "currency": "usd", 89 | "id": "price_1KHkCLL14ex1CGCieIBu8V2e", 90 | "livemode": false, 91 | "lookup_key": null, 92 | "metadata": {}, 93 | "nickname": "Year subscription", 94 | "object": "price", 95 | "product": "prod_KxfXRXOd7dnLbz", 96 | "recurring": { 97 | "aggregate_usage": null, 98 | "interval": "year", 99 | "interval_count": 1, 100 | "trial_period_days": null, 101 | "usage_type": "licensed" 102 | }, 103 | "tax_behavior": "exclusive", 104 | "tiers_mode": null, 105 | "transform_quantity": null, 106 | "type": "recurring", 107 | "unit_amount": 1000, 108 | "unit_amount_decimal": "1000" 109 | }, 110 | "quantity": 1, 111 | "subscription": "sub_1KHlYHL14ex1CGCiIBo8Xk5p", 112 | "tax_rates": [] 113 | } 114 | ], 115 | "has_more": false, 116 | "object": "list", 117 | "total_count": 1, 118 | "url": "/v1/subscription_items?subscription=sub_1KHlYHL14ex1CGCiIBo8Xk5p" 119 | }, 120 | "latest_invoice": "in_1KHlafL14ex1CGCiB2uebagV", 121 | "livemode": false, 122 | "metadata": {}, 123 | "next_pending_invoice_item_invoice": null, 124 | "object": "subscription", 125 | "pause_collection": null, 126 | "payment_settings": { 127 | "payment_method_options": null, 128 | "payment_method_types": null 129 | }, 130 | "pending_invoice_item_interval": null, 131 | "pending_setup_intent": null, 132 | "pending_update": null, 133 | "plan": { 134 | "active": true, 135 | "aggregate_usage": null, 136 | "amount": 1000, 137 | "amount_decimal": "1000", 138 | "billing_scheme": "per_unit", 139 | "created": 1642145265, 140 | "currency": "usd", 141 | "id": "price_1KHkCLL14ex1CGCieIBu8V2e", 142 | "interval": "year", 143 | "interval_count": 1, 144 | "livemode": false, 145 | "metadata": {}, 146 | "nickname": "Year subscription", 147 | "object": "plan", 148 | "product": "prod_KxfXRXOd7dnLbz", 149 | "tiers_mode": null, 150 | "transform_usage": null, 151 | "trial_period_days": null, 152 | "usage_type": "licensed" 153 | }, 154 | "quantity": 1, 155 | "schedule": null, 156 | "start_date": 1642150469, 157 | "status": "active", 158 | "transfer_data": null, 159 | "trial_end": null, 160 | "trial_start": null 161 | }, 162 | "previous_attributes": { 163 | "discount": null 164 | } 165 | }, 166 | "id": "evt_1KHlurL14ex1CGCiJN6mwECg", 167 | "livemode": false, 168 | "object": "event", 169 | "pending_webhooks": 2, 170 | "request": { 171 | "id": "req_l3GefP32WaOiXl", 172 | "idempotency_key": "" 173 | }, 174 | "type": "customer.subscription.updated" 175 | } -------------------------------------------------------------------------------- /tests/mock_responses/2020-08-27/webhook_subscription_updated_billing_frequency.json: -------------------------------------------------------------------------------- 1 | { 2 | "api_version": "2020-08-27", 3 | "created": 1642150619, 4 | "data": { 5 | "object": { 6 | "application_fee_percent": null, 7 | "automatic_tax": { 8 | "enabled": false 9 | }, 10 | "billing_cycle_anchor": 1642150617, 11 | "billing_thresholds": null, 12 | "cancel_at": null, 13 | "cancel_at_period_end": false, 14 | "canceled_at": null, 15 | "collection_method": "charge_automatically", 16 | "created": 1642150469, 17 | "current_period_end": 1673686617, 18 | "current_period_start": 1642150617, 19 | "customer": "cus_tester", 20 | "days_until_due": null, 21 | "default_payment_method": null, 22 | "default_source": null, 23 | "default_tax_rates": [], 24 | "discount": null, 25 | "ended_at": null, 26 | "id": "sub_1KHlYHL14ex1CGCiIBo8Xk5p", 27 | "items": { 28 | "data": [ 29 | { 30 | "billing_thresholds": null, 31 | "created": 1642150617, 32 | "id": "si_Kxgyikq4yxQ70f", 33 | "metadata": {}, 34 | "object": "subscription_item", 35 | "plan": { 36 | "active": true, 37 | "aggregate_usage": null, 38 | "amount": 1000, 39 | "amount_decimal": "1000", 40 | "billing_scheme": "per_unit", 41 | "created": 1642145265, 42 | "currency": "usd", 43 | "id": "price_1KHkCLL14ex1CGCieIBu8V2e", 44 | "interval": "year", 45 | "interval_count": 1, 46 | "livemode": false, 47 | "metadata": {}, 48 | "nickname": "Year subscription", 49 | "object": "plan", 50 | "product": "prod_KxfXRXOd7dnLbz", 51 | "tiers_mode": null, 52 | "transform_usage": null, 53 | "trial_period_days": null, 54 | "usage_type": "licensed" 55 | }, 56 | "price": { 57 | "active": true, 58 | "billing_scheme": "per_unit", 59 | "created": 1642145265, 60 | "currency": "usd", 61 | "id": "price_1KHkCLL14ex1CGCieIBu8V2e", 62 | "livemode": false, 63 | "lookup_key": null, 64 | "metadata": {}, 65 | "nickname": "Year subscription", 66 | "object": "price", 67 | "product": "prod_KxfXRXOd7dnLbz", 68 | "recurring": { 69 | "aggregate_usage": null, 70 | "interval": "year", 71 | "interval_count": 1, 72 | "trial_period_days": null, 73 | "usage_type": "licensed" 74 | }, 75 | "tax_behavior": "exclusive", 76 | "tiers_mode": null, 77 | "transform_quantity": null, 78 | "type": "recurring", 79 | "unit_amount": 1000, 80 | "unit_amount_decimal": "1000" 81 | }, 82 | "quantity": 1, 83 | "subscription": "sub_1KHlYHL14ex1CGCiIBo8Xk5p", 84 | "tax_rates": [] 85 | } 86 | ], 87 | "has_more": false, 88 | "object": "list", 89 | "total_count": 1, 90 | "url": "/v1/subscription_items?subscription=sub_1KHlYHL14ex1CGCiIBo8Xk5p" 91 | }, 92 | "latest_invoice": "in_1KHlafL14ex1CGCiB2uebagV", 93 | "livemode": false, 94 | "metadata": {}, 95 | "next_pending_invoice_item_invoice": null, 96 | "object": "subscription", 97 | "pause_collection": null, 98 | "payment_settings": { 99 | "payment_method_options": null, 100 | "payment_method_types": null 101 | }, 102 | "pending_invoice_item_interval": null, 103 | "pending_setup_intent": null, 104 | "pending_update": null, 105 | "plan": { 106 | "active": true, 107 | "aggregate_usage": null, 108 | "amount": 1000, 109 | "amount_decimal": "1000", 110 | "billing_scheme": "per_unit", 111 | "created": 1642145265, 112 | "currency": "usd", 113 | "id": "price_1KHkCLL14ex1CGCieIBu8V2e", 114 | "interval": "year", 115 | "interval_count": 1, 116 | "livemode": false, 117 | "metadata": {}, 118 | "nickname": "Year subscription", 119 | "object": "plan", 120 | "product": "prod_KxfXRXOd7dnLbz", 121 | "tiers_mode": null, 122 | "transform_usage": null, 123 | "trial_period_days": null, 124 | "usage_type": "licensed" 125 | }, 126 | "quantity": 1, 127 | "schedule": null, 128 | "start_date": 1642150469, 129 | "status": "active", 130 | "transfer_data": null, 131 | "trial_end": null, 132 | "trial_start": null 133 | }, 134 | "previous_attributes": { 135 | "billing_cycle_anchor": 1642150469, 136 | "current_period_end": 1644828869, 137 | "current_period_start": 1642150469, 138 | "items": { 139 | "data": [ 140 | { 141 | "billing_thresholds": null, 142 | "created": 1642150470, 143 | "id": "si_KxgwlJyHxmgJKx", 144 | "metadata": {}, 145 | "object": "subscription_item", 146 | "plan": { 147 | "active": true, 148 | "aggregate_usage": null, 149 | "amount": 100, 150 | "amount_decimal": "100", 151 | "billing_scheme": "per_unit", 152 | "created": 1642145265, 153 | "currency": "usd", 154 | "id": "price_1KHkCLL14ex1CGCipzcBdnOp", 155 | "interval": "month", 156 | "interval_count": 1, 157 | "livemode": false, 158 | "metadata": {}, 159 | "nickname": "Monthly subscription", 160 | "object": "plan", 161 | "product": "prod_KxfXRXOd7dnLbz", 162 | "tiers_mode": null, 163 | "transform_usage": null, 164 | "trial_period_days": null, 165 | "usage_type": "licensed" 166 | }, 167 | "price": { 168 | "active": true, 169 | "billing_scheme": "per_unit", 170 | "created": 1642145265, 171 | "currency": "usd", 172 | "id": "price_1KHkCLL14ex1CGCipzcBdnOp", 173 | "livemode": false, 174 | "lookup_key": null, 175 | "metadata": {}, 176 | "nickname": "Monthly subscription", 177 | "object": "price", 178 | "product": "prod_KxfXRXOd7dnLbz", 179 | "recurring": { 180 | "aggregate_usage": null, 181 | "interval": "month", 182 | "interval_count": 1, 183 | "trial_period_days": null, 184 | "usage_type": "licensed" 185 | }, 186 | "tax_behavior": "exclusive", 187 | "tiers_mode": null, 188 | "transform_quantity": null, 189 | "type": "recurring", 190 | "unit_amount": 100, 191 | "unit_amount_decimal": "100" 192 | }, 193 | "quantity": 1, 194 | "subscription": "sub_1KHlYHL14ex1CGCiIBo8Xk5p", 195 | "tax_rates": [] 196 | } 197 | ] 198 | }, 199 | "latest_invoice": "in_1KHlYIL14ex1CGCiHSj2FQyU", 200 | "plan": { 201 | "amount": 100, 202 | "amount_decimal": "100", 203 | "id": "price_1KHkCLL14ex1CGCipzcBdnOp", 204 | "interval": "month", 205 | "nickname": "Monthly subscription" 206 | } 207 | } 208 | }, 209 | "id": "evt_1KHlaiL14ex1CGCi6tkmOtnK", 210 | "livemode": false, 211 | "object": "event", 212 | "pending_webhooks": 1, 213 | "request": { 214 | "id": "req_oKmVf1vmkkTPqA", 215 | "idempotency_key": "" 216 | }, 217 | "type": "customer.subscription.updated" 218 | } 219 | -------------------------------------------------------------------------------- /tests/mock_responses/2020-08-27/webhook_subscription_updated_cancel_at_period_end.json: -------------------------------------------------------------------------------- 1 | { 2 | "api_version": "2020-08-27", 3 | "created": 1642152041, 4 | "data": { 5 | "object": { 6 | "application_fee_percent": null, 7 | "automatic_tax": { 8 | "enabled": false 9 | }, 10 | "billing_cycle_anchor": 1642150617, 11 | "billing_thresholds": null, 12 | "cancel_at": 1673686617, 13 | "cancel_at_period_end": true, 14 | "canceled_at": 1642152040, 15 | "collection_method": "charge_automatically", 16 | "created": 1642150469, 17 | "current_period_end": 1673686617, 18 | "current_period_start": 1642150617, 19 | "customer": "cus_tester", 20 | "days_until_due": null, 21 | "default_payment_method": null, 22 | "default_source": null, 23 | "default_tax_rates": [], 24 | "discount": { 25 | "checkout_session": null, 26 | "coupon": { 27 | "amount_off": null, 28 | "created": 1642151846, 29 | "currency": null, 30 | "duration": "forever", 31 | "duration_in_months": null, 32 | "id": "Ck2QXzxk", 33 | "livemode": false, 34 | "max_redemptions": 3, 35 | "metadata": {}, 36 | "name": "Test Coupon", 37 | "object": "coupon", 38 | "percent_off": 20.0, 39 | "redeem_by": null, 40 | "times_redeemed": 1, 41 | "valid": true 42 | }, 43 | "customer": "cus_tester", 44 | "end": null, 45 | "id": "di_1KHlurL14ex1CGCit6pd30js", 46 | "invoice": null, 47 | "invoice_item": null, 48 | "object": "discount", 49 | "promotion_code": null, 50 | "start": 1642151869, 51 | "subscription": "sub_1KHlYHL14ex1CGCiIBo8Xk5p" 52 | }, 53 | "ended_at": null, 54 | "id": "sub_1KHlYHL14ex1CGCiIBo8Xk5p", 55 | "items": { 56 | "data": [ 57 | { 58 | "billing_thresholds": null, 59 | "created": 1642150617, 60 | "id": "si_Kxgyikq4yxQ70f", 61 | "metadata": {}, 62 | "object": "subscription_item", 63 | "plan": { 64 | "active": true, 65 | "aggregate_usage": null, 66 | "amount": 1000, 67 | "amount_decimal": "1000", 68 | "billing_scheme": "per_unit", 69 | "created": 1642145265, 70 | "currency": "usd", 71 | "id": "price_1KHkCLL14ex1CGCieIBu8V2e", 72 | "interval": "year", 73 | "interval_count": 1, 74 | "livemode": false, 75 | "metadata": {}, 76 | "nickname": "Year subscription", 77 | "object": "plan", 78 | "product": "prod_KxfXRXOd7dnLbz", 79 | "tiers_mode": null, 80 | "transform_usage": null, 81 | "trial_period_days": null, 82 | "usage_type": "licensed" 83 | }, 84 | "price": { 85 | "active": true, 86 | "billing_scheme": "per_unit", 87 | "created": 1642145265, 88 | "currency": "usd", 89 | "id": "price_1KHkCLL14ex1CGCieIBu8V2e", 90 | "livemode": false, 91 | "lookup_key": null, 92 | "metadata": {}, 93 | "nickname": "Year subscription", 94 | "object": "price", 95 | "product": "prod_KxfXRXOd7dnLbz", 96 | "recurring": { 97 | "aggregate_usage": null, 98 | "interval": "year", 99 | "interval_count": 1, 100 | "trial_period_days": null, 101 | "usage_type": "licensed" 102 | }, 103 | "tax_behavior": "exclusive", 104 | "tiers_mode": null, 105 | "transform_quantity": null, 106 | "type": "recurring", 107 | "unit_amount": 1000, 108 | "unit_amount_decimal": "1000" 109 | }, 110 | "quantity": 1, 111 | "subscription": "sub_1KHlYHL14ex1CGCiIBo8Xk5p", 112 | "tax_rates": [] 113 | } 114 | ], 115 | "has_more": false, 116 | "object": "list", 117 | "total_count": 1, 118 | "url": "/v1/subscription_items?subscription=sub_1KHlYHL14ex1CGCiIBo8Xk5p" 119 | }, 120 | "latest_invoice": "in_1KHlafL14ex1CGCiB2uebagV", 121 | "livemode": false, 122 | "metadata": {}, 123 | "next_pending_invoice_item_invoice": null, 124 | "object": "subscription", 125 | "pause_collection": null, 126 | "payment_settings": { 127 | "payment_method_options": null, 128 | "payment_method_types": null 129 | }, 130 | "pending_invoice_item_interval": null, 131 | "pending_setup_intent": null, 132 | "pending_update": null, 133 | "plan": { 134 | "active": true, 135 | "aggregate_usage": null, 136 | "amount": 1000, 137 | "amount_decimal": "1000", 138 | "billing_scheme": "per_unit", 139 | "created": 1642145265, 140 | "currency": "usd", 141 | "id": "price_1KHkCLL14ex1CGCieIBu8V2e", 142 | "interval": "year", 143 | "interval_count": 1, 144 | "livemode": false, 145 | "metadata": {}, 146 | "nickname": "Year subscription", 147 | "object": "plan", 148 | "product": "prod_KxfXRXOd7dnLbz", 149 | "tiers_mode": null, 150 | "transform_usage": null, 151 | "trial_period_days": null, 152 | "usage_type": "licensed" 153 | }, 154 | "quantity": 1, 155 | "schedule": null, 156 | "start_date": 1642150469, 157 | "status": "active", 158 | "transfer_data": null, 159 | "trial_end": null, 160 | "trial_start": null 161 | }, 162 | "previous_attributes": { 163 | "cancel_at": null, 164 | "cancel_at_period_end": false, 165 | "canceled_at": null 166 | } 167 | }, 168 | "id": "evt_1KHlxdL14ex1CGCiEyDzqli5", 169 | "livemode": false, 170 | "object": "event", 171 | "pending_webhooks": 2, 172 | "request": { 173 | "id": "req_emUg2iueaHFBIO", 174 | "idempotency_key": "" 175 | }, 176 | "type": "customer.subscription.updated" 177 | } -------------------------------------------------------------------------------- /tests/mock_responses/2020-08-27/webhook_subscription_updated_cancel_immediate.json: -------------------------------------------------------------------------------- 1 | { 2 | "api_version": "2020-08-27", 3 | "created": 1642152635, 4 | "data": { 5 | "object": { 6 | "application_fee_percent": null, 7 | "automatic_tax": { 8 | "enabled": false 9 | }, 10 | "billing_cycle_anchor": 1642150617, 11 | "billing_thresholds": null, 12 | "cancel_at": null, 13 | "cancel_at_period_end": false, 14 | "canceled_at": 1642152635, 15 | "collection_method": "charge_automatically", 16 | "created": 1642150469, 17 | "current_period_end": 1673686617, 18 | "current_period_start": 1642150617, 19 | "customer": "cus_tester", 20 | "days_until_due": null, 21 | "default_payment_method": null, 22 | "default_source": null, 23 | "default_tax_rates": [], 24 | "discount": { 25 | "checkout_session": null, 26 | "coupon": { 27 | "amount_off": null, 28 | "created": 1642151846, 29 | "currency": null, 30 | "duration": "forever", 31 | "duration_in_months": null, 32 | "id": "Ck2QXzxk", 33 | "livemode": false, 34 | "max_redemptions": 3, 35 | "metadata": {}, 36 | "name": "Test Coupon", 37 | "object": "coupon", 38 | "percent_off": 20.0, 39 | "redeem_by": null, 40 | "times_redeemed": 1, 41 | "valid": true 42 | }, 43 | "customer": "cus_tester", 44 | "end": null, 45 | "id": "di_1KHlurL14ex1CGCit6pd30js", 46 | "invoice": null, 47 | "invoice_item": null, 48 | "object": "discount", 49 | "promotion_code": null, 50 | "start": 1642151869, 51 | "subscription": "sub_1KHlYHL14ex1CGCiIBo8Xk5p" 52 | }, 53 | "ended_at": 1642152635, 54 | "id": "sub_1KHlYHL14ex1CGCiIBo8Xk5p", 55 | "items": { 56 | "data": [ 57 | { 58 | "billing_thresholds": null, 59 | "created": 1642150617, 60 | "id": "si_Kxgyikq4yxQ70f", 61 | "metadata": {}, 62 | "object": "subscription_item", 63 | "plan": { 64 | "active": true, 65 | "aggregate_usage": null, 66 | "amount": 1000, 67 | "amount_decimal": "1000", 68 | "billing_scheme": "per_unit", 69 | "created": 1642145265, 70 | "currency": "usd", 71 | "id": "price_1KHkCLL14ex1CGCieIBu8V2e", 72 | "interval": "year", 73 | "interval_count": 1, 74 | "livemode": false, 75 | "metadata": {}, 76 | "nickname": "Year subscription", 77 | "object": "plan", 78 | "product": "prod_KxfXRXOd7dnLbz", 79 | "tiers_mode": null, 80 | "transform_usage": null, 81 | "trial_period_days": null, 82 | "usage_type": "licensed" 83 | }, 84 | "price": { 85 | "active": true, 86 | "billing_scheme": "per_unit", 87 | "created": 1642145265, 88 | "currency": "usd", 89 | "id": "price_1KHkCLL14ex1CGCieIBu8V2e", 90 | "livemode": false, 91 | "lookup_key": null, 92 | "metadata": {}, 93 | "nickname": "Year subscription", 94 | "object": "price", 95 | "product": "prod_KxfXRXOd7dnLbz", 96 | "recurring": { 97 | "aggregate_usage": null, 98 | "interval": "year", 99 | "interval_count": 1, 100 | "trial_period_days": null, 101 | "usage_type": "licensed" 102 | }, 103 | "tax_behavior": "exclusive", 104 | "tiers_mode": null, 105 | "transform_quantity": null, 106 | "type": "recurring", 107 | "unit_amount": 1000, 108 | "unit_amount_decimal": "1000" 109 | }, 110 | "quantity": 1, 111 | "subscription": "sub_1KHlYHL14ex1CGCiIBo8Xk5p", 112 | "tax_rates": [] 113 | } 114 | ], 115 | "has_more": false, 116 | "object": "list", 117 | "total_count": 1, 118 | "url": "/v1/subscription_items?subscription=sub_1KHlYHL14ex1CGCiIBo8Xk5p" 119 | }, 120 | "latest_invoice": "in_1KHlafL14ex1CGCiB2uebagV", 121 | "livemode": false, 122 | "metadata": {}, 123 | "next_pending_invoice_item_invoice": null, 124 | "object": "subscription", 125 | "pause_collection": null, 126 | "payment_settings": { 127 | "payment_method_options": null, 128 | "payment_method_types": null 129 | }, 130 | "pending_invoice_item_interval": null, 131 | "pending_setup_intent": null, 132 | "pending_update": null, 133 | "plan": { 134 | "active": true, 135 | "aggregate_usage": null, 136 | "amount": 1000, 137 | "amount_decimal": "1000", 138 | "billing_scheme": "per_unit", 139 | "created": 1642145265, 140 | "currency": "usd", 141 | "id": "price_1KHkCLL14ex1CGCieIBu8V2e", 142 | "interval": "year", 143 | "interval_count": 1, 144 | "livemode": false, 145 | "metadata": {}, 146 | "nickname": "Year subscription", 147 | "object": "plan", 148 | "product": "prod_KxfXRXOd7dnLbz", 149 | "tiers_mode": null, 150 | "transform_usage": null, 151 | "trial_period_days": null, 152 | "usage_type": "licensed" 153 | }, 154 | "quantity": 1, 155 | "schedule": null, 156 | "start_date": 1642150469, 157 | "status": "canceled", 158 | "transfer_data": null, 159 | "trial_end": null, 160 | "trial_start": null 161 | } 162 | }, 163 | "id": "evt_1KHm7DL14ex1CGCikRTP5Ieo", 164 | "livemode": false, 165 | "object": "event", 166 | "pending_webhooks": 2, 167 | "request": { 168 | "id": "req_uvjUMdtriFr6UP", 169 | "idempotency_key": null 170 | }, 171 | "type": "customer.subscription.deleted" 172 | } -------------------------------------------------------------------------------- /tests/mock_responses/2020-08-27/webhook_subscription_updated_renew_plan.json: -------------------------------------------------------------------------------- 1 | { 2 | "api_version": "2020-08-27", 3 | "created": 1642152338, 4 | "data": { 5 | "object": { 6 | "application_fee_percent": null, 7 | "automatic_tax": { 8 | "enabled": false 9 | }, 10 | "billing_cycle_anchor": 1642150617, 11 | "billing_thresholds": null, 12 | "cancel_at": null, 13 | "cancel_at_period_end": false, 14 | "canceled_at": null, 15 | "collection_method": "charge_automatically", 16 | "created": 1642150469, 17 | "current_period_end": 1673686617, 18 | "current_period_start": 1642150617, 19 | "customer": "cus_tester", 20 | "days_until_due": null, 21 | "default_payment_method": null, 22 | "default_source": null, 23 | "default_tax_rates": [], 24 | "discount": { 25 | "checkout_session": null, 26 | "coupon": { 27 | "amount_off": null, 28 | "created": 1642151846, 29 | "currency": null, 30 | "duration": "forever", 31 | "duration_in_months": null, 32 | "id": "Ck2QXzxk", 33 | "livemode": false, 34 | "max_redemptions": 3, 35 | "metadata": {}, 36 | "name": "Test Coupon", 37 | "object": "coupon", 38 | "percent_off": 20.0, 39 | "redeem_by": null, 40 | "times_redeemed": 1, 41 | "valid": true 42 | }, 43 | "customer": "cus_tester", 44 | "end": null, 45 | "id": "di_1KHlurL14ex1CGCit6pd30js", 46 | "invoice": null, 47 | "invoice_item": null, 48 | "object": "discount", 49 | "promotion_code": null, 50 | "start": 1642151869, 51 | "subscription": "sub_1KHlYHL14ex1CGCiIBo8Xk5p" 52 | }, 53 | "ended_at": null, 54 | "id": "sub_1KHlYHL14ex1CGCiIBo8Xk5p", 55 | "items": { 56 | "data": [ 57 | { 58 | "billing_thresholds": null, 59 | "created": 1642150617, 60 | "id": "si_Kxgyikq4yxQ70f", 61 | "metadata": {}, 62 | "object": "subscription_item", 63 | "plan": { 64 | "active": true, 65 | "aggregate_usage": null, 66 | "amount": 1000, 67 | "amount_decimal": "1000", 68 | "billing_scheme": "per_unit", 69 | "created": 1642145265, 70 | "currency": "usd", 71 | "id": "price_1KHkCLL14ex1CGCieIBu8V2e", 72 | "interval": "year", 73 | "interval_count": 1, 74 | "livemode": false, 75 | "metadata": {}, 76 | "nickname": "Year subscription", 77 | "object": "plan", 78 | "product": "prod_KxfXRXOd7dnLbz", 79 | "tiers_mode": null, 80 | "transform_usage": null, 81 | "trial_period_days": null, 82 | "usage_type": "licensed" 83 | }, 84 | "price": { 85 | "active": true, 86 | "billing_scheme": "per_unit", 87 | "created": 1642145265, 88 | "currency": "usd", 89 | "id": "price_1KHkCLL14ex1CGCieIBu8V2e", 90 | "livemode": false, 91 | "lookup_key": null, 92 | "metadata": {}, 93 | "nickname": "Year subscription", 94 | "object": "price", 95 | "product": "prod_KxfXRXOd7dnLbz", 96 | "recurring": { 97 | "aggregate_usage": null, 98 | "interval": "year", 99 | "interval_count": 1, 100 | "trial_period_days": null, 101 | "usage_type": "licensed" 102 | }, 103 | "tax_behavior": "exclusive", 104 | "tiers_mode": null, 105 | "transform_quantity": null, 106 | "type": "recurring", 107 | "unit_amount": 1000, 108 | "unit_amount_decimal": "1000" 109 | }, 110 | "quantity": 1, 111 | "subscription": "sub_1KHlYHL14ex1CGCiIBo8Xk5p", 112 | "tax_rates": [] 113 | } 114 | ], 115 | "has_more": false, 116 | "object": "list", 117 | "total_count": 1, 118 | "url": "/v1/subscription_items?subscription=sub_1KHlYHL14ex1CGCiIBo8Xk5p" 119 | }, 120 | "latest_invoice": "in_1KHlafL14ex1CGCiB2uebagV", 121 | "livemode": false, 122 | "metadata": {}, 123 | "next_pending_invoice_item_invoice": null, 124 | "object": "subscription", 125 | "pause_collection": null, 126 | "payment_settings": { 127 | "payment_method_options": null, 128 | "payment_method_types": null 129 | }, 130 | "pending_invoice_item_interval": null, 131 | "pending_setup_intent": null, 132 | "pending_update": null, 133 | "plan": { 134 | "active": true, 135 | "aggregate_usage": null, 136 | "amount": 1000, 137 | "amount_decimal": "1000", 138 | "billing_scheme": "per_unit", 139 | "created": 1642145265, 140 | "currency": "usd", 141 | "id": "price_1KHkCLL14ex1CGCieIBu8V2e", 142 | "interval": "year", 143 | "interval_count": 1, 144 | "livemode": false, 145 | "metadata": {}, 146 | "nickname": "Year subscription", 147 | "object": "plan", 148 | "product": "prod_KxfXRXOd7dnLbz", 149 | "tiers_mode": null, 150 | "transform_usage": null, 151 | "trial_period_days": null, 152 | "usage_type": "licensed" 153 | }, 154 | "quantity": 1, 155 | "schedule": null, 156 | "start_date": 1642150469, 157 | "status": "active", 158 | "transfer_data": null, 159 | "trial_end": null, 160 | "trial_start": null 161 | }, 162 | "previous_attributes": { 163 | "cancel_at": 1673686617, 164 | "cancel_at_period_end": true, 165 | "canceled_at": 1642152040 166 | } 167 | }, 168 | "id": "evt_1KHm2QL14ex1CGCijm2ZO0gX", 169 | "livemode": false, 170 | "object": "event", 171 | "pending_webhooks": 2, 172 | "request": { 173 | "id": "req_t9H2CfgmiVAhAJ", 174 | "idempotency_key": "" 175 | }, 176 | "type": "customer.subscription.updated" 177 | } -------------------------------------------------------------------------------- /tests/mock_responses/v1/api_customer_list_2_items.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": [ 3 | { 4 | "address": null, 5 | "balance": 0, 6 | "created": 1642279004, 7 | "currency": "usd", 8 | "default_source": null, 9 | "delinquent": false, 10 | "description": null, 11 | "discount": null, 12 | "email": "tester1@example.com", 13 | "id": "cus_tester", 14 | "invoice_prefix": "C12345AB", 15 | "invoice_settings": { 16 | "custom_fields": null, 17 | "default_payment_method": null, 18 | "footer": null 19 | }, 20 | "livemode": false, 21 | "metadata": {}, 22 | "name": null, 23 | "next_invoice_sequence": 3, 24 | "object": "customer", 25 | "phone": null, 26 | "preferred_locales": [], 27 | "shipping": null, 28 | "tax_exempt": "none" 29 | }, 30 | { 31 | "address": null, 32 | "balance": 0, 33 | "created": 1642275637, 34 | "currency": "usd", 35 | "default_source": null, 36 | "delinquent": false, 37 | "description": null, 38 | "discount": null, 39 | "email": "tester2@example.com", 40 | "id": "cus_tester2", 41 | "invoice_prefix": "C12345AC", 42 | "invoice_settings": { 43 | "custom_fields": null, 44 | "default_payment_method": null, 45 | "footer": null 46 | }, 47 | "livemode": false, 48 | "metadata": {}, 49 | "name": null, 50 | "next_invoice_sequence": 2, 51 | "object": "customer", 52 | "phone": null, 53 | "preferred_locales": [], 54 | "shipping": null, 55 | "tax_exempt": "none" 56 | }, 57 | { 58 | "address": null, 59 | "balance": 0, 60 | "created": 1642275649, 61 | "currency": "usd", 62 | "default_source": null, 63 | "delinquent": false, 64 | "description": null, 65 | "discount": null, 66 | "email": "tester3@example.com", 67 | "id": "cus_tester3", 68 | "invoice_prefix": "C12345CC", 69 | "invoice_settings": { 70 | "custom_fields": null, 71 | "default_payment_method": null, 72 | "footer": null 73 | }, 74 | "livemode": false, 75 | "metadata": {}, 76 | "name": null, 77 | "next_invoice_sequence": 2, 78 | "object": "customer", 79 | "phone": null, 80 | "preferred_locales": [], 81 | "shipping": null, 82 | "tax_exempt": "none" 83 | } 84 | ], 85 | "has_more": false, 86 | "object": "list", 87 | "url": "/v1/customers" 88 | } -------------------------------------------------------------------------------- /tests/mock_responses/v1/api_price_list.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": [ 3 | { 4 | "active": true, 5 | "billing_scheme": "per_unit", 6 | "created": 1642147629, 7 | "currency": "usd", 8 | "id": "price_1KHkoTL14ex1CGCiV8X4cJs5", 9 | "livemode": false, 10 | "lookup_key": null, 11 | "metadata": {}, 12 | "nickname": null, 13 | "object": "price", 14 | "product": "prod_KxgA5goLUMwnoN", 15 | "recurring": { 16 | "aggregate_usage": null, 17 | "interval": "month", 18 | "interval_count": 1, 19 | "trial_period_days": null, 20 | "usage_type": "licensed" 21 | }, 22 | "tax_behavior": "exclusive", 23 | "tiers_mode": null, 24 | "transform_quantity": null, 25 | "type": "recurring", 26 | "unit_amount": 200, 27 | "unit_amount_decimal": "200" 28 | }, 29 | { 30 | "active": true, 31 | "billing_scheme": "per_unit", 32 | "created": 1642145265, 33 | "currency": "usd", 34 | "id": "price_1KHkCLL14ex1CGCipzcBdnOp", 35 | "livemode": false, 36 | "lookup_key": null, 37 | "metadata": {}, 38 | "nickname": "Monthly subscription", 39 | "object": "price", 40 | "product": "prod_KxfXRXOd7dnLbz", 41 | "recurring": { 42 | "aggregate_usage": null, 43 | "interval": "month", 44 | "interval_count": 1, 45 | "trial_period_days": null, 46 | "usage_type": "licensed" 47 | }, 48 | "tax_behavior": "exclusive", 49 | "tiers_mode": null, 50 | "transform_quantity": null, 51 | "type": "recurring", 52 | "unit_amount": 100, 53 | "unit_amount_decimal": "100" 54 | }, 55 | { 56 | "active": true, 57 | "billing_scheme": "per_unit", 58 | "created": 1642145265, 59 | "currency": "usd", 60 | "id": "price_1KHkCLL14ex1CGCieIBu8V2e", 61 | "livemode": false, 62 | "lookup_key": null, 63 | "metadata": {}, 64 | "nickname": "Year subscription", 65 | "object": "price", 66 | "product": "prod_KxfXRXOd7dnLbz", 67 | "recurring": { 68 | "aggregate_usage": null, 69 | "interval": "year", 70 | "interval_count": 1, 71 | "trial_period_days": null, 72 | "usage_type": "licensed" 73 | }, 74 | "tax_behavior": "exclusive", 75 | "tiers_mode": null, 76 | "transform_quantity": null, 77 | "type": "recurring", 78 | "unit_amount": 1000, 79 | "unit_amount_decimal": "1000" 80 | } 81 | ], 82 | "has_more": false, 83 | "object": "list", 84 | "url": "/v1/prices" 85 | } 86 | -------------------------------------------------------------------------------- /tests/mock_responses/v1/api_product_list.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": [ 3 | { 4 | "active": true, 5 | "attributes": [], 6 | "created": 1642147629, 7 | "description": "Test Product ABC", 8 | "id": "prod_KxgA5goLUMwnoN", 9 | "images": [], 10 | "livemode": false, 11 | "metadata": { 12 | "features": "A B C" 13 | }, 14 | "name": "Test Product ABC", 15 | "object": "product", 16 | "package_dimensions": null, 17 | "shippable": null, 18 | "statement_descriptor": null, 19 | "tax_code": "txcd_10000000", 20 | "type": "service", 21 | "unit_label": null, 22 | "updated": 1642147629, 23 | "url": null 24 | }, 25 | { 26 | "active": true, 27 | "attributes": [], 28 | "created": 1642145265, 29 | "description": "Test Product ABD", 30 | "id": "prod_KxfXRXOd7dnLbz", 31 | "images": [], 32 | "livemode": false, 33 | "metadata": { 34 | "features": "A B D" 35 | }, 36 | "name": "Test Product ABD", 37 | "object": "product", 38 | "package_dimensions": null, 39 | "shippable": null, 40 | "statement_descriptor": null, 41 | "tax_code": "txcd_10000000", 42 | "type": "service", 43 | "unit_label": null, 44 | "updated": 1642145979, 45 | "url": null 46 | } 47 | ], 48 | "has_more": false, 49 | "object": "list", 50 | "url": "/v1/products" 51 | } 52 | -------------------------------------------------------------------------------- /tests/mock_responses/v1/api_subscription_list.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": [ 3 | { 4 | "application_fee_percent": null, 5 | "automatic_tax": { 6 | "enabled": false 7 | }, 8 | "billing_cycle_anchor": 1643577603, 9 | "billing_thresholds": null, 10 | "cancel_at": null, 11 | "cancel_at_period_end": false, 12 | "canceled_at": null, 13 | "collection_method": "charge_automatically", 14 | "created": 1642281612, 15 | "current_period_end": 1643577603, 16 | "current_period_start": 1642281612, 17 | "customer": "cus_tester", 18 | "days_until_due": null, 19 | "default_payment_method": "pm_0001", 20 | "default_source": null, 21 | "default_tax_rates": [], 22 | "discount": null, 23 | "ended_at": null, 24 | "id": "sub_0001", 25 | "items": { 26 | "data": [ 27 | { 28 | "billing_thresholds": null, 29 | "created": 1642281613, 30 | "id": "si_0001", 31 | "metadata": {}, 32 | "object": "subscription_item", 33 | "plan": { 34 | "active": true, 35 | "aggregate_usage": null, 36 | "amount": 1500, 37 | "amount_decimal": "1500", 38 | "billing_scheme": "per_unit", 39 | "created": 1635018592, 40 | "currency": "usd", 41 | "id": "price_1KHkCLL14ex1CGCipzcBdnOp", 42 | "interval": "month", 43 | "interval_count": 1, 44 | "livemode": false, 45 | "metadata": {}, 46 | "nickname": "Monthly Subscription", 47 | "object": "plan", 48 | "product": "prod_KxfXRXOd7dnLbz", 49 | "tiers_mode": null, 50 | "transform_usage": null, 51 | "trial_period_days": null, 52 | "usage_type": "licensed" 53 | }, 54 | "price": { 55 | "active": true, 56 | "billing_scheme": "per_unit", 57 | "created": 1635018592, 58 | "currency": "usd", 59 | "id": "price_1KHkCLL14ex1CGCipzcBdnOp", 60 | "livemode": false, 61 | "lookup_key": null, 62 | "metadata": {}, 63 | "nickname": "Monthly Subscription", 64 | "object": "price", 65 | "product": "prod_KxfXRXOd7dnLbz", 66 | "recurring": { 67 | "aggregate_usage": null, 68 | "interval": "month", 69 | "interval_count": 1, 70 | "trial_period_days": null, 71 | "usage_type": "licensed" 72 | }, 73 | "tax_behavior": "unspecified", 74 | "tiers_mode": null, 75 | "transform_quantity": null, 76 | "type": "recurring", 77 | "unit_amount": 1500, 78 | "unit_amount_decimal": "1500" 79 | }, 80 | "quantity": 1, 81 | "subscription": "sub_0001", 82 | "tax_rates": [] 83 | } 84 | ], 85 | "has_more": false, 86 | "object": "list", 87 | "total_count": 1, 88 | "url": "/v1/subscription_items?subscription=sub_0001" 89 | }, 90 | "latest_invoice": "in_00001", 91 | "livemode": false, 92 | "metadata": {}, 93 | "next_pending_invoice_item_invoice": null, 94 | "object": "subscription", 95 | "pause_collection": null, 96 | "payment_settings": { 97 | "payment_method_options": null, 98 | "payment_method_types": null 99 | }, 100 | "pending_invoice_item_interval": null, 101 | "pending_setup_intent": null, 102 | "pending_update": null, 103 | "plan": { 104 | "active": true, 105 | "aggregate_usage": null, 106 | "amount": 1500, 107 | "amount_decimal": "1500", 108 | "billing_scheme": "per_unit", 109 | "created": 1635018592, 110 | "currency": "usd", 111 | "id": "price_1KHkCLL14ex1CGCipzcBdnOp", 112 | "interval": "month", 113 | "interval_count": 1, 114 | "livemode": false, 115 | "metadata": {}, 116 | "nickname": "Monthly Subscription", 117 | "object": "plan", 118 | "product": "prod_KxfXRXOd7dnLbz", 119 | "tiers_mode": null, 120 | "transform_usage": null, 121 | "trial_period_days": null, 122 | "usage_type": "licensed" 123 | }, 124 | "quantity": 1, 125 | "schedule": null, 126 | "start_date": 1642281612, 127 | "status": "trialing", 128 | "transfer_data": null, 129 | "trial_end": 1643577603, 130 | "trial_start": 1642281612 131 | }, 132 | { 133 | "application_fee_percent": null, 134 | "automatic_tax": { 135 | "enabled": false 136 | }, 137 | "billing_cycle_anchor": 1643577603, 138 | "billing_thresholds": null, 139 | "cancel_at": null, 140 | "cancel_at_period_end": false, 141 | "canceled_at": null, 142 | "collection_method": "charge_automatically", 143 | "created": 1642281612, 144 | "current_period_end": 1643577603, 145 | "current_period_start": 1642281612, 146 | "customer": "cus_tester2", 147 | "days_until_due": null, 148 | "default_payment_method": "pm_0001", 149 | "default_source": null, 150 | "default_tax_rates": [], 151 | "discount": null, 152 | "ended_at": null, 153 | "id": "sub_0002", 154 | "items": { 155 | "data": [ 156 | { 157 | "billing_thresholds": null, 158 | "created": 1642281613, 159 | "id": "si_0002", 160 | "metadata": {}, 161 | "object": "subscription_item", 162 | "plan": { 163 | "active": true, 164 | "aggregate_usage": null, 165 | "amount": 1500, 166 | "amount_decimal": "1500", 167 | "billing_scheme": "per_unit", 168 | "created": 1635018592, 169 | "currency": "usd", 170 | "id": "price_1KHkCLL14ex1CGCipzcBdnOp", 171 | "interval": "month", 172 | "interval_count": 1, 173 | "livemode": false, 174 | "metadata": {}, 175 | "nickname": "Monthly Subscription", 176 | "object": "plan", 177 | "product": "prod_KxfXRXOd7dnLbz", 178 | "tiers_mode": null, 179 | "transform_usage": null, 180 | "trial_period_days": null, 181 | "usage_type": "licensed" 182 | }, 183 | "price": { 184 | "active": true, 185 | "billing_scheme": "per_unit", 186 | "created": 1635018592, 187 | "currency": "usd", 188 | "id": "price_1KHkCLL14ex1CGCipzcBdnOp", 189 | "livemode": false, 190 | "lookup_key": null, 191 | "metadata": {}, 192 | "nickname": "Monthly Subscription", 193 | "object": "price", 194 | "product": "prod_KxfXRXOd7dnLbz", 195 | "recurring": { 196 | "aggregate_usage": null, 197 | "interval": "month", 198 | "interval_count": 1, 199 | "trial_period_days": null, 200 | "usage_type": "licensed" 201 | }, 202 | "tax_behavior": "unspecified", 203 | "tiers_mode": null, 204 | "transform_quantity": null, 205 | "type": "recurring", 206 | "unit_amount": 1500, 207 | "unit_amount_decimal": "1500" 208 | }, 209 | "quantity": 1, 210 | "subscription": "sub_0001", 211 | "tax_rates": [] 212 | } 213 | ], 214 | "has_more": false, 215 | "object": "list", 216 | "total_count": 1, 217 | "url": "/v1/subscription_items?subscription=sub_0001" 218 | }, 219 | "latest_invoice": "in_00001", 220 | "livemode": false, 221 | "metadata": {}, 222 | "next_pending_invoice_item_invoice": null, 223 | "object": "subscription", 224 | "pause_collection": null, 225 | "payment_settings": { 226 | "payment_method_options": null, 227 | "payment_method_types": null 228 | }, 229 | "pending_invoice_item_interval": null, 230 | "pending_setup_intent": null, 231 | "pending_update": null, 232 | "plan": { 233 | "active": true, 234 | "aggregate_usage": null, 235 | "amount": 1500, 236 | "amount_decimal": "1500", 237 | "billing_scheme": "per_unit", 238 | "created": 1635018592, 239 | "currency": "usd", 240 | "id": "price_1KHkCLL14ex1CGCipzcBdnOp", 241 | "interval": "month", 242 | "interval_count": 1, 243 | "livemode": false, 244 | "metadata": {}, 245 | "nickname": "Monthly Subscription", 246 | "object": "plan", 247 | "product": "prod_KxfXRXOd7dnLbz", 248 | "tiers_mode": null, 249 | "transform_usage": null, 250 | "trial_period_days": null, 251 | "usage_type": "licensed" 252 | }, 253 | "quantity": 1, 254 | "schedule": null, 255 | "start_date": 1642281612, 256 | "status": "trialing", 257 | "transfer_data": null, 258 | "trial_end": 1643577603, 259 | "trial_start": 1642281612 260 | } 261 | ], 262 | "has_more": false, 263 | "object": "list", 264 | "url": "/v1/subscriptions" 265 | } -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | ADMINS = () 2 | 3 | MANAGERS = ADMINS 4 | 5 | DATABASES = { 6 | "default": { 7 | "ENGINE": "django.db.backends.sqlite3", 8 | "NAME": ":memory:", 9 | } 10 | } 11 | 12 | REST_FRAMEWORK = { 13 | 14 | 'DEFAULT_PERMISSION_CLASSES': ( 15 | 'rest_framework.permissions.IsAuthenticated', 16 | ), 17 | 'DEFAULT_RENDERER_CLASSES': ( 18 | 'rest_framework.renderers.JSONRenderer', 19 | ), 20 | } 21 | 22 | ALLOWED_HOSTS = [] 23 | 24 | TIME_ZONE = "UTC" 25 | 26 | MEDIA_ROOT = "" 27 | MEDIA_URL = "" 28 | 29 | STATIC_ROOT = "" 30 | STATIC_URL = "/static/" 31 | 32 | STATICFILES_DIRS = () 33 | 34 | STATICFILES_FINDERS = ( 35 | "django.contrib.staticfiles.finders.FileSystemFinder", 36 | "django.contrib.staticfiles.finders.AppDirectoriesFinder", 37 | ) 38 | 39 | SECRET_KEY = "1234567890" 40 | 41 | TEMPLATES = [ 42 | { 43 | "BACKEND": "django.template.backends.django.DjangoTemplates", 44 | "DIRS": [], 45 | "APP_DIRS": True, 46 | "OPTIONS": { 47 | "debug": True, 48 | "context_processors": [ 49 | "django.contrib.auth.context_processors.auth", 50 | "django.template.context_processors.debug", 51 | "django.template.context_processors.i18n", 52 | "django.template.context_processors.media", 53 | "django.template.context_processors.static", 54 | "django.template.context_processors.tz", 55 | "django.contrib.messages.context_processors.messages", 56 | ], 57 | }, 58 | }, 59 | ] 60 | 61 | MIDDLEWARE = ( 62 | "django.middleware.common.CommonMiddleware", 63 | "django.contrib.sessions.middleware.SessionMiddleware", 64 | "django.middleware.csrf.CsrfViewMiddleware", 65 | "django.contrib.auth.middleware.AuthenticationMiddleware", 66 | "django.contrib.messages.middleware.MessageMiddleware", 67 | ) 68 | 69 | ROOT_URLCONF = "tests.urls" 70 | 71 | INSTALLED_APPS = ( 72 | "django.contrib.auth", 73 | "django.contrib.contenttypes", 74 | "django.contrib.sessions", 75 | "django.contrib.sites", 76 | "django.contrib.staticfiles", 77 | "django.contrib.admin", 78 | "django.contrib.messages", 79 | "rest_framework", 80 | "drf_stripe", 81 | "tests", 82 | ) 83 | 84 | USE_TZ = True 85 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.urls import include, path 3 | 4 | admin.autodiscover() 5 | 6 | urlpatterns = [ 7 | path("stripe/", include("drf_stripe.urls")) 8 | ] 9 | -------------------------------------------------------------------------------- /tests/webhook/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oscarychen/drf-stripe-subscription/83cf9e3080f5fc9603790badc5323f90c3053402/tests/webhook/__init__.py -------------------------------------------------------------------------------- /tests/webhook/test_event_product_price.py: -------------------------------------------------------------------------------- 1 | from django.db.models import Q 2 | 3 | from drf_stripe.models import Product, Price, ProductFeature 4 | from drf_stripe.stripe_webhooks.handler import handle_webhook_event 5 | from tests.base import BaseTest 6 | 7 | 8 | class TestWebhookProductPriceEvents(BaseTest): 9 | 10 | def create_product_price(self): 11 | event = self._load_test_data("2020-08-27/webhook_product_created.json") 12 | handle_webhook_event(event) 13 | event = self._load_test_data("2020-08-27/webhook_price_created.json") 14 | handle_webhook_event(event) 15 | 16 | def test_event_handler_product_created(self): 17 | """ 18 | Mock production and price creation events 19 | """ 20 | self.create_product_price() 21 | 22 | # check product and price created 23 | product = Product.objects.get(description='Test Product ABC') 24 | price = Price.objects.get(product=product) 25 | self.assertEqual(price.price, 100) 26 | self.assertEqual(price.product, product) 27 | 28 | # check Product-to-Feature relations created 29 | ProductFeature.objects.get(product=product, feature__feature_id='A') 30 | ProductFeature.objects.get(product=product, feature__feature_id='B') 31 | ProductFeature.objects.get(product=product, feature__feature_id='C') 32 | 33 | def test_event_handler_price_update(self): 34 | """ 35 | Mock price update events 36 | """ 37 | self.create_product_price() 38 | 39 | # modify price 40 | event = self._load_test_data("2020-08-27/webhook_price_updated.json") 41 | handle_webhook_event(event) 42 | 43 | # check price modifications 44 | 45 | price = Price.objects.get(price_id="price_1KHkCLL14ex1CGCipzcBdnOp") 46 | 47 | self.assertEqual(price.price, 50) 48 | self.assertEqual(price.freq, "week_1") 49 | self.assertEqual(price.nickname, "Weekly subscription") 50 | self.assertEqual(price.product.product_id, "prod_KxfXRXOd7dnLbz") 51 | 52 | def test_event_handler_product_update(self): 53 | """Mock product update event""" 54 | self.create_product_price() 55 | 56 | # modify product 57 | product_mod = self._load_test_data("2020-08-27/webhook_product_updated.json") 58 | handle_webhook_event(product_mod) 59 | 60 | # check product modifications 61 | product = Product.objects.get(product_id='prod_KxfXRXOd7dnLbz') 62 | self.assertEqual(product.name, "Test Product ABD") 63 | self.assertEqual(product.description, "Test Product ABD") 64 | 65 | # check product is now associated with feature D 66 | ProductFeature.objects.get(product=product, feature__feature_id='D') 67 | ProductFeature.objects.get(product=product, feature__feature_id='A') 68 | ProductFeature.objects.get(product=product, feature__feature_id='B') 69 | 70 | # check product no longer associated with feature C 71 | prod_feature_qs = ProductFeature.objects.filter(Q(product=product) & Q(feature__feature_id='C')) 72 | self.assertEqual(len(prod_feature_qs), 0) 73 | 74 | def test_event_handler_price_archived(self): 75 | """Mock price archived event""" 76 | self.create_product_price() 77 | 78 | event = self._load_test_data("2020-08-27/webhook_price_updated_archived.json") 79 | handle_webhook_event(event) 80 | price = Price.objects.get(price_id='price_1KHkCLL14ex1CGCieIBu8V2e') 81 | self.assertFalse(price.active) 82 | 83 | def test_event_handler_product_archived(self): 84 | """Mock product archived event""" 85 | self.create_product_price() 86 | 87 | event = self._load_test_data("2020-08-27/webhook_product_updated_archived.json") 88 | handle_webhook_event(event) 89 | product = Product.objects.get(product_id='prod_KxfXRXOd7dnLbz') 90 | self.assertFalse(product.active) 91 | -------------------------------------------------------------------------------- /tests/webhook/test_subscription.py: -------------------------------------------------------------------------------- 1 | from drf_stripe.models import Subscription, SubscriptionItem 2 | from drf_stripe.stripe_webhooks.handler import handle_webhook_event 3 | from ..base import BaseTest 4 | 5 | 6 | class TestWebhookSubscriptionEvents(BaseTest): 7 | 8 | def setUp(self) -> None: 9 | self.setup_product_prices() 10 | self.setup_user_customer() 11 | 12 | def create_subscription(self): 13 | # create subscription 14 | event = self._load_test_data("2020-08-27/webhook_subscription_created.json") 15 | handle_webhook_event(event) 16 | 17 | def test_event_handler_subscription_created(self): 18 | """Mock customer subscription creation event""" 19 | 20 | self.create_subscription() 21 | 22 | # check subscription instance is created 23 | subscription = Subscription.objects.get(subscription_id="sub_1KHlYHL14ex1CGCiIBo8Xk5p") 24 | self.assertEqual(subscription.stripe_user.customer_id, "cus_tester") 25 | 26 | # check subscription item instance is created 27 | sub_item = SubscriptionItem.objects.get(sub_item_id="si_KxgwlJyHxmgJKx") 28 | self.assertEqual(sub_item.subscription.subscription_id, subscription.subscription_id) 29 | self.assertEqual(sub_item.price.price_id, "price_1KHkCLL14ex1CGCipzcBdnOp") 30 | self.assertEqual(sub_item.price.product.product_id, "prod_KxfXRXOd7dnLbz") 31 | 32 | def test_event_handler_subscription_updated_price_plan_change(self): 33 | """Mock customer subscription being modified event""" 34 | 35 | self.create_subscription() 36 | 37 | event = self._load_test_data("2020-08-27/webhook_subscription_updated_billing_frequency.json") 38 | handle_webhook_event(event) 39 | 40 | # check subscription is updated with new price 41 | subscription = Subscription.objects.get(subscription_id="sub_1KHlYHL14ex1CGCiIBo8Xk5p") 42 | 43 | # check SubscriptionItem with previous price is deleted 44 | old = SubscriptionItem.objects.filter(price_id="price_1KHkCLL14ex1CGCipzcBdnOp") 45 | self.assertEqual(len(old), 0) 46 | 47 | # check SubscriptionItem with new price is created 48 | sub_item = SubscriptionItem.objects.get(price_id="price_1KHkCLL14ex1CGCieIBu8V2e") 49 | self.assertEqual(sub_item.subscription.subscription_id, subscription.subscription_id) 50 | 51 | def test_event_handler_subscription_updated_coupon_added(self): 52 | self.create_subscription() 53 | 54 | event = self._load_test_data("2020-08-27/webhook_subscription_updated_apply_coupon.json") 55 | handle_webhook_event(event) 56 | 57 | # TODO: coupon not implemented yet 58 | 59 | def test_event_handler_subscription_updated_cancel_at_period_end(self): 60 | """Mock customer subscription cancelling at end of period""" 61 | self.create_subscription() 62 | event = self._load_test_data("2020-08-27/webhook_subscription_updated_cancel_at_period_end.json") 63 | handle_webhook_event(event) 64 | 65 | subscription = Subscription.objects.get(subscription_id="sub_1KHlYHL14ex1CGCiIBo8Xk5p") 66 | self.assertTrue(subscription.cancel_at_period_end) 67 | self.assertIsNotNone(subscription.cancel_at) 68 | 69 | def test_event_handler_subscription_updated_renewed(self): 70 | """Mock customer subscription renewed""" 71 | self.create_subscription() 72 | event = self._load_test_data("2020-08-27/webhook_subscription_updated_cancel_at_period_end.json") 73 | handle_webhook_event(event) 74 | event = self._load_test_data("2020-08-27/webhook_subscription_updated_renew_plan.json") 75 | handle_webhook_event(event) 76 | 77 | subscription = Subscription.objects.get(subscription_id="sub_1KHlYHL14ex1CGCiIBo8Xk5p") 78 | self.assertFalse(subscription.cancel_at_period_end) 79 | self.assertIsNone(subscription.cancel_at) 80 | self.assertIsNone(subscription.ended_at) 81 | 82 | def test_event_handler_subscription_updated_cancel_immediately(self): 83 | """Mock customer subscription cancelling immediately""" 84 | self.create_subscription() 85 | event = self._load_test_data("2020-08-27/webhook_subscription_updated_cancel_immediate.json") 86 | handle_webhook_event(event) 87 | 88 | subscription = Subscription.objects.get(subscription_id="sub_1KHlYHL14ex1CGCiIBo8Xk5p") 89 | self.assertIsNotNone(subscription.ended_at) 90 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | lint-py{38} 4 | dj{32,42}-py{312,310,39,38} 5 | dj{50}-py{312,310} 6 | 7 | skip_missing_interpreters = 8 | true 9 | 10 | [gh-actions] 11 | python = 12 | 3.8: py38 13 | 3.9: py39 14 | 3.10: py310 15 | 3.12: py312 16 | DJANGO= 17 | 3.2: dj32 18 | 4.2: dj42 19 | 5.0: dj50 20 | 21 | [testenv] 22 | deps = 23 | {[base]deps} 24 | dj32: Django>=3.2,<4.0 25 | dj42: Django>=4.2,<5.0 26 | dj50: Django>=5.0,<6.0 27 | commands = pytest 28 | setenv = 29 | DJANGO_SETTINGS_MODULE = tests.settings 30 | PYTHONPATH = {toxinidir} 31 | PYTHONWARNINGS = all 32 | 33 | [pytest] 34 | django_find_project = false 35 | python_files = test_*.py 36 | 37 | [base] 38 | deps = 39 | -r requirements.txt 40 | pytest 41 | pytest-django 42 | --------------------------------------------------------------------------------