├── .gitignore ├── README.md ├── django_paddle ├── __init__.py ├── admin.py ├── apps.py ├── client.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ ├── paddle_sync_payments.py │ │ ├── paddle_sync_plans.py │ │ └── paddle_sync_subscriptions.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_paddlesubscription_cancellation_effective_date.py │ └── __init__.py ├── models.py ├── receivers.py ├── signals.py ├── urls.py ├── utils.py └── views.py ├── runtests.py ├── setup.py └── tests ├── client ├── __init__.py └── test_paddle_client.py ├── helpers.py ├── models ├── __init__.py ├── test_paddle_plan.py └── test_paddle_subscription.py ├── settings.py ├── urls.py └── webhooks ├── __init__.py └── test_signals.py /.gitignore: -------------------------------------------------------------------------------- 1 | # PyCharm 2 | .idea/ 3 | 4 | # Byte-compiled / optimized / DLL files 5 | __pycache__/ 6 | *.py[cod] 7 | *$py.class 8 | 9 | # C extensions 10 | *.so 11 | 12 | # Distribution / packaging 13 | .Python 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | wheels/ 26 | *.egg-info/ 27 | .installed.cfg 28 | *.egg 29 | MANIFEST 30 | 31 | # PyInstaller 32 | # Usually these files are written by a python script from a template 33 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 34 | *.manifest 35 | *.spec 36 | 37 | # Installer logs 38 | pip-log.txt 39 | pip-delete-this-directory.txt 40 | 41 | # Unit test / coverage reports 42 | htmlcov/ 43 | .tox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | 53 | # Translations 54 | *.mo 55 | *.pot 56 | 57 | # Django stuff: 58 | *.log 59 | local_settings.py 60 | db.sqlite3 61 | 62 | # Flask stuff: 63 | instance/ 64 | .webassets-cache 65 | 66 | # Scrapy stuff: 67 | .scrapy 68 | 69 | # Sphinx documentation 70 | docs/_build/ 71 | 72 | # PyBuilder 73 | target/ 74 | 75 | # Jupyter Notebook 76 | .ipynb_checkpoints 77 | 78 | # pyenv 79 | .python-version 80 | 81 | # celery beat schedule file 82 | celerybeat-schedule 83 | 84 | # SageMath parsed files 85 | *.sage.py 86 | 87 | # Environments 88 | .env 89 | .venv 90 | env/ 91 | venv/ 92 | ENV/ 93 | env.bak/ 94 | venv.bak/ 95 | 96 | # Spyder project settings 97 | .spyderproject 98 | .spyproject 99 | 100 | # Rope project settings 101 | .ropeproject 102 | 103 | # mkdocs documentation 104 | /site 105 | 106 | # mypy 107 | .mypy_cache/ 108 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # django-paddle 2 | 3 | Django models and helpers for integrating Paddle.com subscriptions with your Django app 4 | 5 | ⚠️This library is very much **WORK IN PROGRESS**, please read this document carefully to understand what is currently supported. 6 | 7 | Currently this package includes: 8 | 9 | * Django Models for plans, plan prices, subscriptions, payments (invoices) 10 | * Django management commands for sycing plans, subscriptions, payments 11 | * Webhook receivers that handle subscription creation, subscription cancellation 12 | 13 | ### Installation 14 | 15 | Requires: 16 | 17 | * Python 3.6+ 18 | * Django 2.1.0+ 19 | 20 | 1. Install the `django-paddle` package 21 | 22 | ``` 23 | pip install django-paddle 24 | ``` 25 | 26 | 2. Add `django_paddle` to your INSTALLED_APPS 27 | 28 | ```python 29 | INSTALLED_APPS = [ 30 | # ... 31 | 'django_paddle', 32 | # ... 33 | ] 34 | ``` 35 | 36 | 3. In your `settings.py` add the following settings: 37 | 38 | ```python 39 | PADDLE_VENDOR_ID = 'your-vendor-id-here' # https://vendors.paddle.com/authentication 40 | PADDLE_AUTH_CODE = 'your-auth-code-here' # https://vendors.paddle.com/authentication 41 | PADDLE_PUBLIC_KEY = """-----BEGIN PUBLIC KEY----- 42 | your 43 | public 44 | key 45 | here 46 | -----END PUBLIC KEY-----""" # https://vendors.paddle.com/public-key 47 | PADDLE_ACCOUNT_MODEL = 'auth.User' 48 | ``` 49 | 50 | ℹ️ If you are using the default Django User model, set `PADDLE_ACCOUNT_MODEL` to `auth.User`. If you are using a custom User model set this to something like `your_custom_app.YourUserModel`. 51 | 52 | 5. In your projects main `urls.py` add the `django_paddle` URLs for receiving webhooks: 53 | 54 | ```python 55 | urlpatterns = [ 56 | path('', include('django_paddle.urls')), 57 | ] 58 | ``` 59 | 60 | ℹ️ This will result in an absolute webhook URL `https://example.com/webhook`. Make sure this is the Webhook URL you set in your Paddle settings (https://vendors.paddle.com/alerts-webhooks). 61 | 62 | 4. Run migrations 63 | 64 | `python manage.py migrate` 65 | 66 | The User Model specified in `PADDLE_ACCOUNT_MODEL` will now have a back-reference to the PaddleSubscription and vice versa. 67 | 68 | Example: 69 | 70 | ```python 71 | sub = PaddleSubscription.objects.all()[0] 72 | print(sub.account) # 73 | ``` 74 | 75 | or 76 | 77 | ```python 78 | user = User.objects.get(username='johndoe@example.com') 79 | print(u.subscriptions.all()) # ]> 80 | ``` 81 | 82 | 5. Done! 83 | 84 | 85 | ### Automatically connecting Users and Subscriptions 86 | 87 | We need a shared identifier between the User model and the PaddleSubscription model. This needs to be provided when we redirect a user to the Paddle checkout. If you are using the default Django User model you can provide a unique user ID as a passthrough value. The `subscription_created` webook will check the passtrough field and see if a User with this ID exists and automatically connect it to the newly created subscription. 88 | 89 | Example: 90 | 91 | ```html 92 | 93 | 101 | ``` 102 | 103 | ### Django Management Commands 104 | 105 | * `manage.py paddle_sync_plans` - Syncs Subscription Plans 106 | * `manage.py paddle_sync_subscriptions` - Syncs Subscriptions 107 | * `manage.py paddle_sync_payments` - Syncs payments for all subscriptions 108 | 109 | ### Run tests 110 | 111 | ``` 112 | python runtests.py 113 | ``` 114 | -------------------------------------------------------------------------------- /django_paddle/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kennell/django-paddle/b74ffea4c84d652ee0a8066a33762818c7f69d00/django_paddle/__init__.py -------------------------------------------------------------------------------- /django_paddle/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django_paddle.models import PaddlePlan, PaddleInitialPrice, PaddleRecurringPrice, PaddleSubscription 3 | 4 | 5 | class PaddleInitialPriceInline(admin.TabularInline): 6 | model = PaddleInitialPrice 7 | 8 | 9 | class PaddleRecurringPriceInline(admin.TabularInline): 10 | model = PaddleRecurringPrice 11 | 12 | 13 | class PaddlePlanAdmin(admin.ModelAdmin): 14 | list_display = ['id', 'name', 'billing_type', 'billing_period'] 15 | inlines = [PaddleInitialPriceInline, PaddleRecurringPriceInline] 16 | 17 | 18 | class PaddleSubscriptionAdmin(admin.ModelAdmin): 19 | list_display = ['id', 'signup_date', 'user_email', 'state'] 20 | 21 | 22 | admin.site.register(PaddlePlan, PaddlePlanAdmin) 23 | admin.site.register(PaddleSubscription, PaddleSubscriptionAdmin) 24 | -------------------------------------------------------------------------------- /django_paddle/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class DjangoPaddleConfig(AppConfig): 5 | name = 'django_paddle' 6 | verbose_name = 'Django Paddle' 7 | -------------------------------------------------------------------------------- /django_paddle/client.py: -------------------------------------------------------------------------------- 1 | from copy import copy 2 | import requests 3 | from django.conf import settings 4 | 5 | 6 | class PaddleClient: 7 | 8 | def __init__(self): 9 | self.base_url = 'https://vendors.paddle.com/api/2.0/' 10 | self.vendor_id = settings.PADDLE_VENDOR_ID 11 | self.vendor_auth_code = settings.PADDLE_AUTH_CODE 12 | self.base_payload = { 13 | 'vendor_id': self.vendor_id, 14 | 'vendor_auth_code': self.vendor_auth_code 15 | } 16 | 17 | # Plans 18 | 19 | def plans_list(self): 20 | rsp = requests.post( 21 | url=self.base_url + 'subscription/plans', 22 | json=self.base_payload 23 | ) 24 | return rsp.json()['response'] 25 | 26 | def plans_get(self, plan_id): 27 | payload = copy(self.base_payload) 28 | payload.update(plan_id=plan_id) 29 | rsp = requests.post( 30 | url=self.base_url + 'subscription/plans', 31 | json=payload 32 | ) 33 | return rsp.json()['response'][0] 34 | 35 | # Subscriptions 36 | 37 | def subscriptions_list(self, state=None): 38 | 39 | """ 40 | :param state: filter by state, returns all active, past_due, trialing 41 | and paused subscription plans if not specified. 42 | Will NOT return deleted subscriptions 43 | See https://developer.paddle.com/api-reference/subscription-api/users/listusers 44 | """ 45 | 46 | subscriptions = [] 47 | max_results = 200 48 | payload = copy(self.base_payload) 49 | payload.update(page=1, results_per_page=max_results) 50 | 51 | if state: 52 | payload.update(state=state) 53 | 54 | while True: 55 | data = requests.post( 56 | url=self.base_url + 'subscription/users', 57 | json=payload 58 | ).json()['response'] 59 | subscriptions += data 60 | if len(data) < max_results: 61 | break 62 | else: 63 | payload['page'] += 1 64 | 65 | return subscriptions 66 | 67 | def subscriptions_get(self, subscription_id): 68 | payload = copy(self.base_payload) 69 | payload.update(subscription_id=subscription_id) 70 | return requests.post( 71 | url=self.base_url + 'subscription/users', 72 | json=payload 73 | ).json()['response'][0] 74 | 75 | def subscriptions_cancel(self, subscription_id): 76 | payload = copy(self.base_payload) 77 | payload.update( 78 | subscription_id=subscription_id 79 | ) 80 | requests.post( 81 | url=self.base_url + 'subscription/users_cancel', 82 | json=payload 83 | ) 84 | 85 | def subscriptions_pause(self, subscription_id): 86 | payload = copy(self.base_payload) 87 | payload.update( 88 | subscription_id=subscription_id, 89 | pause=True 90 | ) 91 | requests.post( 92 | url=self.base_url + 'subscription/users/update', 93 | json=payload 94 | ) 95 | 96 | def subscriptions_unpause(self, subscription_id): 97 | payload = copy(self.base_payload) 98 | payload.update( 99 | subscription_id=subscription_id, 100 | pause=False 101 | ) 102 | requests.post( 103 | url=self.base_url + 'subscription/users/update', 104 | json=payload 105 | ) 106 | 107 | # Payments 108 | 109 | def payments_list(self, subscription_id=None, is_paid=None): 110 | payload = copy(self.base_payload) 111 | if subscription_id: 112 | payload.update(subscription_id=subscription_id) 113 | if is_paid is not None: 114 | payload.update(is_paid=is_paid) 115 | return requests.post( 116 | url=self.base_url + 'subscription/payments', 117 | json=payload 118 | ).json()['response'] 119 | 120 | # Transactions 121 | 122 | def transactions_list(self, entity, id): 123 | payload = copy(self.base_payload) 124 | return requests.post( 125 | url=self.base_url + '{}/{}/transactions'.format(entity, id), 126 | json=payload 127 | ).json()['response'] 128 | -------------------------------------------------------------------------------- /django_paddle/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kennell/django-paddle/b74ffea4c84d652ee0a8066a33762818c7f69d00/django_paddle/management/__init__.py -------------------------------------------------------------------------------- /django_paddle/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kennell/django-paddle/b74ffea4c84d652ee0a8066a33762818c7f69d00/django_paddle/management/commands/__init__.py -------------------------------------------------------------------------------- /django_paddle/management/commands/paddle_sync_payments.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand 2 | from django_paddle.models import PaddleSubscription, PaddlePayment 3 | 4 | 5 | class Command(BaseCommand): 6 | help = 'Sync payments' 7 | 8 | def handle(self, *args, **options): 9 | for subscription in PaddleSubscription.objects.all(): 10 | subscription.sync_payments() 11 | -------------------------------------------------------------------------------- /django_paddle/management/commands/paddle_sync_plans.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand 2 | from django_paddle.models import PaddlePlan 3 | 4 | 5 | class Command(BaseCommand): 6 | help = 'Sync plans' 7 | 8 | def handle(self, *args, **options): 9 | PaddlePlan.sync() 10 | -------------------------------------------------------------------------------- /django_paddle/management/commands/paddle_sync_subscriptions.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand 2 | from django_paddle.models import PaddleSubscription 3 | 4 | 5 | class Command(BaseCommand): 6 | help = 'Sync subscriptions' 7 | 8 | def handle(self, *args, **options): 9 | PaddleSubscription.sync() # sync default 10 | PaddleSubscription.sync(state='deleted') # also sync deleted 11 | -------------------------------------------------------------------------------- /django_paddle/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.4 on 2020-05-09 22:04 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [ 13 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name='PaddlePlan', 19 | fields=[ 20 | ('id', models.PositiveIntegerField(primary_key=True, serialize=False, unique=True)), 21 | ('name', models.CharField(max_length=255)), 22 | ('billing_type', models.CharField(max_length=255)), 23 | ('billing_period', models.PositiveIntegerField()), 24 | ('trial_days', models.PositiveIntegerField()), 25 | ], 26 | ), 27 | migrations.CreateModel( 28 | name='PaddleSubscription', 29 | fields=[ 30 | ('id', models.PositiveIntegerField(primary_key=True, serialize=False, unique=True)), 31 | ('user_id', models.PositiveIntegerField()), 32 | ('user_email', models.EmailField(max_length=254)), 33 | ('marketing_consent', models.BooleanField()), 34 | ('update_url', models.CharField(max_length=255)), 35 | ('cancel_url', models.CharField(max_length=255)), 36 | ('state', models.CharField(max_length=255)), 37 | ('signup_date', models.DateTimeField()), 38 | ('account', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='subscriptions', to=settings.AUTH_USER_MODEL)), 39 | ('plan', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='subscriptions', to='django_paddle.PaddlePlan')), 40 | ], 41 | ), 42 | migrations.CreateModel( 43 | name='PaddleRecurringPrice', 44 | fields=[ 45 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 46 | ('currency', models.CharField(max_length=255)), 47 | ('amount', models.CharField(max_length=255)), 48 | ('plan', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='recurring_prices', to='django_paddle.PaddlePlan')), 49 | ], 50 | options={ 51 | 'default_related_name': 'recurring_prices', 52 | }, 53 | ), 54 | migrations.CreateModel( 55 | name='PaddlePayment', 56 | fields=[ 57 | ('id', models.PositiveIntegerField(primary_key=True, serialize=False, unique=True)), 58 | ('amount', models.PositiveIntegerField()), 59 | ('currency', models.CharField(max_length=255)), 60 | ('payout_date', models.DateField(max_length=255)), 61 | ('is_paid', models.BooleanField()), 62 | ('is_one_off_charge', models.BooleanField()), 63 | ('receipt_url', models.CharField(max_length=255, null=True)), 64 | ('subscription', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='payments', to='django_paddle.PaddleSubscription')), 65 | ], 66 | ), 67 | migrations.CreateModel( 68 | name='PaddleInitialPrice', 69 | fields=[ 70 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 71 | ('currency', models.CharField(max_length=255)), 72 | ('amount', models.CharField(max_length=255)), 73 | ('plan', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='initial_prices', to='django_paddle.PaddlePlan')), 74 | ], 75 | options={ 76 | 'default_related_name': 'initial_prices', 77 | }, 78 | ), 79 | ] 80 | -------------------------------------------------------------------------------- /django_paddle/migrations/0002_paddlesubscription_cancellation_effective_date.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.4 on 2021-11-28 15:32 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('django_paddle', '0001_initial'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='paddlesubscription', 15 | name='cancellation_effective_date', 16 | field=models.DateTimeField(default=None, null=True, blank=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /django_paddle/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kennell/django-paddle/b74ffea4c84d652ee0a8066a33762818c7f69d00/django_paddle/migrations/__init__.py -------------------------------------------------------------------------------- /django_paddle/models.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from django.db import models 4 | from django.utils import timezone 5 | from django_paddle.client import PaddleClient 6 | from django_paddle.utils import get_account_model, get_account_by_passthrough 7 | 8 | 9 | pc = PaddleClient() 10 | 11 | 12 | class PaddlePlan(models.Model): 13 | id = models.PositiveIntegerField( 14 | primary_key=True, 15 | unique=True 16 | ) 17 | name = models.CharField(max_length=255) 18 | billing_type = models.CharField(max_length=255) 19 | billing_period = models.PositiveIntegerField() 20 | trial_days = models.PositiveIntegerField() 21 | 22 | def initial_price_in(self, currency): 23 | return self.initial_prices.get(currency=currency).amount 24 | 25 | def recurring_price_in(self, currency): 26 | return self.recurring_prices.get(currency=currency).amount 27 | 28 | @staticmethod 29 | def sync(): 30 | for plan in pc.plans_list(): 31 | plan_obj, _ = PaddlePlan.objects.update_or_create( 32 | id=plan['id'], 33 | defaults={ 34 | 'name': plan['name'], 35 | 'billing_type': plan['billing_type'], 36 | 'billing_period': plan['billing_period'], 37 | 'trial_days': plan['trial_days'] 38 | } 39 | ) 40 | for currency, amount in plan['initial_price'].items(): 41 | plan_obj.initial_prices.update_or_create( 42 | currency=currency, 43 | defaults={ 44 | 'amount': amount 45 | } 46 | ) 47 | for currency, amount in plan['recurring_price'].items(): 48 | plan_obj.recurring_prices.update_or_create( 49 | currency=currency, 50 | defaults={ 51 | 'amount': amount 52 | } 53 | ) 54 | 55 | 56 | class PaddlePrice(models.Model): 57 | plan = models.ForeignKey( 58 | to=PaddlePlan, 59 | on_delete=models.CASCADE, 60 | ) 61 | currency = models.CharField(max_length=255) 62 | amount = models.CharField(max_length=255) 63 | 64 | class Meta: 65 | abstract = True 66 | unique_together = ['plan', 'currency'] 67 | 68 | 69 | class PaddleInitialPrice(PaddlePrice): 70 | class Meta: 71 | default_related_name = 'initial_prices' 72 | 73 | 74 | class PaddleRecurringPrice(PaddlePrice): 75 | 76 | class Meta: 77 | default_related_name = 'recurring_prices' 78 | 79 | 80 | class PaddleSubscription(models.Model): 81 | 82 | # TODO: use enum types for state field instead of plain varchar strings 83 | # https://docs.djangoproject.com/en/3.0/ref/models/fields/#enumeration-types 84 | 85 | id = models.PositiveIntegerField( 86 | primary_key=True, 87 | unique=True 88 | ) 89 | account = models.ForeignKey( 90 | to=get_account_model(), 91 | null=True, 92 | on_delete=models.SET_NULL, 93 | related_name='subscriptions' 94 | ) 95 | plan = models.ForeignKey( 96 | to=PaddlePlan, 97 | null=True, 98 | on_delete=models.SET_NULL, 99 | related_name='subscriptions' 100 | ) 101 | user_id = models.PositiveIntegerField() 102 | user_email = models.EmailField() 103 | marketing_consent = models.BooleanField() 104 | update_url = models.CharField(max_length=255) 105 | cancel_url = models.CharField(max_length=255) 106 | state = models.CharField(max_length=255) 107 | signup_date = models.DateTimeField() 108 | cancellation_effective_date = models.DateTimeField(null=True, blank=True, default=None) 109 | 110 | @property 111 | def is_canceled(self): 112 | return bool(self.cancellation_effective_date) 113 | 114 | @property 115 | def is_trialing(self): 116 | return self.state == 'trialing' 117 | 118 | @property 119 | def is_active(self): 120 | if self.state in ['active', 'past_due']: 121 | return True 122 | 123 | if self.is_canceled: 124 | if timezone.now() < self.cancellation_effective_date: 125 | return True 126 | 127 | return False 128 | 129 | def cancel(self): 130 | pc.subscriptions_cancel(self.id) 131 | self.state = 'deleted' 132 | self.save() 133 | 134 | def pause(self): 135 | pc.subscriptions_pause(subscription_id=self.id) 136 | self.state = 'paused' 137 | self.save() 138 | 139 | def unpause(self): 140 | pc.subscriptions_unpause(subscription_id=self.id) 141 | self.state = 'active' 142 | self.save() 143 | 144 | def sync_payments(self): 145 | for payment in pc.payments_list(subscription_id=self.id): 146 | defaults = { 147 | 'amount': payment['amount'], 148 | 'currency': payment['currency'], 149 | 'payout_date': timezone.make_aware(datetime.strptime(payment['payout_date'], '%Y-%m-%d')), 150 | 'is_paid': payment['is_paid'], 151 | 'is_one_off_charge': payment['is_one_off_charge'], 152 | } 153 | if 'receipt_url' in payment: 154 | defaults['receipt_url'] = payment['receipt_url'] 155 | PaddlePayment.objects.update_or_create( 156 | id=payment['id'], 157 | subscription=self, 158 | defaults=defaults 159 | ) 160 | 161 | @staticmethod 162 | def sync(state=None): 163 | for sub in pc.subscriptions_list(state=state): 164 | transaction = pc.transactions_list(entity='subscription', id=sub['subscription_id'])[0] 165 | account = get_account_by_passthrough(transaction['passthrough']) 166 | 167 | try: 168 | plan = PaddlePlan.objects.get(id=sub['plan_id']) 169 | except PaddlePlan.DoesNotExist: 170 | plan = None 171 | 172 | defaults = { 173 | 'user_id': sub['user_id'], 174 | 'user_email': sub['user_email'], 175 | 'marketing_consent': sub['marketing_consent'], 176 | 'update_url': sub['update_url'], 177 | 'cancel_url': sub['cancel_url'], 178 | 'state': sub['state'], 179 | 'signup_date': timezone.make_aware(datetime.strptime(sub['signup_date'], '%Y-%m-%d %H:%M:%S')) 180 | } 181 | 182 | if plan: 183 | defaults['plan'] = plan 184 | 185 | if account: 186 | defaults['account'] = account 187 | 188 | PaddleSubscription.objects.update_or_create( 189 | id=sub['subscription_id'], 190 | defaults=defaults 191 | ) 192 | 193 | 194 | class PaddlePayment(models.Model): 195 | id = models.PositiveIntegerField( 196 | unique=True, 197 | primary_key=True 198 | ) 199 | subscription = models.ForeignKey( 200 | to=PaddleSubscription, 201 | on_delete=models.CASCADE, 202 | related_name='payments' 203 | ) 204 | amount = models.PositiveIntegerField() 205 | currency = models.CharField(max_length=255) 206 | payout_date = models.DateField(max_length=255) 207 | is_paid = models.BooleanField() 208 | is_one_off_charge = models.BooleanField() 209 | receipt_url = models.CharField(max_length=255, null=True) 210 | -------------------------------------------------------------------------------- /django_paddle/receivers.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from django.dispatch import receiver 3 | from django.utils.timezone import make_aware 4 | from .signals import subscription_created, subscription_cancelled, subscription_updated 5 | from .models import PaddlePlan, PaddleSubscription 6 | from .utils import get_account_by_passthrough 7 | 8 | 9 | @receiver(subscription_created) 10 | def subscription_created_receiver(**kwargs): 11 | payload = kwargs['payload'] 12 | 13 | account = get_account_by_passthrough(payload['passthrough']) 14 | 15 | try: 16 | plan = PaddlePlan.objects.get(id=payload['subscription_plan_id']) 17 | except PaddlePlan.DoesNotExist: 18 | # raise warning/exception here 19 | plan = None 20 | 21 | PaddleSubscription.objects.create( 22 | id=payload['subscription_id'], 23 | account=account, 24 | plan=plan, 25 | user_id=payload['user_id'], 26 | user_email=payload['email'], 27 | marketing_consent=bool(payload['marketing_consent']), 28 | update_url=payload['update_url'], 29 | cancel_url=payload['cancel_url'], 30 | state=payload['status'], 31 | signup_date=make_aware(datetime.strptime(payload['event_time'], '%Y-%m-%d %H:%M:%S')), 32 | cancellation_effective_date=None 33 | ) 34 | 35 | 36 | @receiver(subscription_cancelled) 37 | def subscription_cancelled_receiver(**kwargs): 38 | payload = kwargs['payload'] 39 | 40 | try: 41 | subscription = PaddleSubscription.objects.get(id=payload['subscription_id']) 42 | except PaddleSubscription.DoesNotExist: 43 | # raise warning/exception here 44 | pass 45 | 46 | subscription.cancellation_effective_date = make_aware(datetime.strptime(payload['cancellation_effective_date'], '%Y-%m-%d')) 47 | subscription.state = payload['status'] 48 | subscription.save() 49 | 50 | 51 | @receiver(subscription_updated) 52 | def subscription_updated_receiver(**kwargs): 53 | payload = kwargs['payload'] 54 | 55 | try: 56 | plan = PaddlePlan.objects.get(id=payload['subscription_plan_id']) 57 | except PaddlePlan.DoesNotExist: 58 | # raise warning/exception here 59 | plan = None 60 | 61 | try: 62 | subscription = PaddleSubscription.objects.get(id=payload['subscription_id']) 63 | except PaddleSubscription.DoesNotExist: 64 | # raise warning/exception here 65 | pass 66 | 67 | subscription.state = payload['status'] 68 | subscription.plan = plan 69 | subscription.save() 70 | -------------------------------------------------------------------------------- /django_paddle/signals.py: -------------------------------------------------------------------------------- 1 | from django.dispatch import Signal 2 | 3 | 4 | class WebhookSignalFactory(): 5 | 6 | def __new__(self): 7 | return Signal() 8 | 9 | 10 | # Subscriptions 11 | subscription_created = WebhookSignalFactory() 12 | subscription_updated = WebhookSignalFactory() 13 | subscription_cancelled = WebhookSignalFactory() 14 | subscription_payment_succeeded = WebhookSignalFactory() 15 | subscription_payment_failed = WebhookSignalFactory() 16 | subscription_payment_refunded = WebhookSignalFactory() 17 | 18 | # One-off Purchases 19 | locker_processed = WebhookSignalFactory() 20 | payment_succeeded = WebhookSignalFactory() 21 | payment_refunded = WebhookSignalFactory() 22 | 23 | # Risk & Dispute Alerts 24 | payment_dispute_created = WebhookSignalFactory() 25 | payment_dispute_closed = WebhookSignalFactory() 26 | high_risk_transaction_created = WebhookSignalFactory() 27 | high_risk_transaction_updated = WebhookSignalFactory() 28 | 29 | # Payout Alerts 30 | transfer_created = WebhookSignalFactory() 31 | transfer_paid = WebhookSignalFactory() 32 | 33 | # Audience Alerts 34 | new_audience_member = WebhookSignalFactory() 35 | update_audience_member = WebhookSignalFactory() 36 | -------------------------------------------------------------------------------- /django_paddle/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from django_paddle import views 4 | 5 | 6 | urlpatterns = [ 7 | path("webhook", views.webhook, name="django_paddle_webhook"), 8 | ] 9 | -------------------------------------------------------------------------------- /django_paddle/utils.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import collections 3 | import json 4 | 5 | import phpserialize 6 | from cryptography import exceptions 7 | from cryptography.hazmat.backends import default_backend 8 | from cryptography.hazmat.primitives.asymmetric.padding import PKCS1v15 9 | from cryptography.hazmat.primitives.hashes import SHA1 10 | from cryptography.hazmat.primitives.serialization import load_pem_public_key 11 | from django.conf import settings 12 | from django.apps import apps 13 | 14 | 15 | def get_account_model(): 16 | app, model = settings.PADDLE_ACCOUNT_MODEL.split('.') 17 | return apps.get_model(app, model, require_ready=False) 18 | 19 | 20 | def get_account_by_passthrough(passthrough): 21 | if passthrough: 22 | try: 23 | passthrough = json.loads(passthrough) 24 | account_id = passthrough['account_id'] 25 | except json.decoder.JSONDecodeError: 26 | account_id = passthrough 27 | else: 28 | account_id = None 29 | 30 | try: 31 | account = get_account_model().objects.get(id=account_id) 32 | except get_account_model().DoesNotExist: 33 | account = None 34 | 35 | return account 36 | 37 | 38 | def webhook_signature_is_valid(payload): 39 | 40 | signature = base64.b64decode(payload.pop('p_signature')) 41 | 42 | for field in payload: 43 | payload[field] = str(payload[field]) 44 | 45 | sorted_data = collections.OrderedDict(sorted(payload.items())) 46 | serialized_data = phpserialize.dumps(sorted_data) 47 | 48 | public_key = load_pem_public_key(settings.PADDLE_PUBLIC_KEY.encode(), backend=default_backend()) 49 | 50 | try: 51 | public_key.verify( 52 | signature=signature, 53 | data=serialized_data, 54 | padding=PKCS1v15(), 55 | algorithm=SHA1() 56 | ) 57 | return True 58 | except exceptions.InvalidSignature: 59 | return False 60 | -------------------------------------------------------------------------------- /django_paddle/views.py: -------------------------------------------------------------------------------- 1 | from django.http import HttpResponse 2 | from .utils import webhook_signature_is_valid 3 | from django.views.decorators.http import require_POST 4 | from django.views.decorators.csrf import csrf_exempt 5 | from django_paddle import signals 6 | 7 | 8 | class Webhook: 9 | pass 10 | 11 | 12 | @csrf_exempt 13 | @require_POST 14 | def webhook(request): 15 | payload = request.POST.dict() 16 | 17 | if webhook_signature_is_valid(payload): 18 | alert_name = payload['alert_name'] 19 | signal = getattr(signals, alert_name) 20 | if signal: 21 | signal.send( 22 | sender=Webhook, 23 | payload=payload 24 | ) 25 | else: 26 | # Log warning if alert_name does not match any signal? 27 | pass 28 | 29 | return HttpResponse() 30 | -------------------------------------------------------------------------------- /runtests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | import django 6 | from django.conf import settings 7 | from django.test.utils import get_runner 8 | 9 | 10 | if __name__ == '__main__': 11 | os.environ['DJANGO_SETTINGS_MODULE'] = 'tests.settings' 12 | django.setup() 13 | TestRunner = get_runner(settings) 14 | test_runner = TestRunner() 15 | failures = test_runner.run_tests(['tests']) 16 | sys.exit(bool(failures)) 17 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | 4 | setup( 5 | name='django-paddle', 6 | version='0.0.16', 7 | packages=find_packages(), 8 | description='Django models for integrating Paddle.com subscriptions', 9 | url='https://github.com/kennell/django-paddle', 10 | author='Kevin Kennell', 11 | author_email='kevin@kennell.de', 12 | install_requires=[ 13 | 'django', 14 | 'requests', 15 | 'cryptography', 16 | 'phpserialize' 17 | ], 18 | extras_require={ 19 | 'dev': [ 20 | 'responses', 21 | ] 22 | } 23 | ) 24 | -------------------------------------------------------------------------------- /tests/client/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kennell/django-paddle/b74ffea4c84d652ee0a8066a33762818c7f69d00/tests/client/__init__.py -------------------------------------------------------------------------------- /tests/client/test_paddle_client.py: -------------------------------------------------------------------------------- 1 | import responses 2 | from django.test import TestCase 3 | 4 | from django_paddle.client import PaddleClient 5 | 6 | 7 | class TestPaddleClient(TestCase): 8 | 9 | def setUp(self): 10 | self.client = PaddleClient() 11 | 12 | # Plans 13 | 14 | @responses.activate 15 | def test_plans_list(self): 16 | expected = { 17 | 'success': True, 18 | 'response': [ 19 | { 20 | 'id': 12345, 21 | 'name': 'Foo', 22 | 'billing_type': 'month', 23 | 'billing_period': 1, 24 | 'trial_days': 14, 25 | 'initial_price': { 26 | 'USD': '10.00', 27 | 'EUR': '10.00' 28 | }, 29 | 'recurring_price': { 30 | 'USD': '10.00', 31 | 'EUR': '10.00' 32 | } 33 | } 34 | ] 35 | } 36 | responses.add( 37 | method=responses.POST, 38 | url='https://vendors.paddle.com/api/2.0/subscription/plans', 39 | json=expected 40 | ) 41 | self.assertListEqual( 42 | self.client.plans_list(), expected['response'] 43 | ) 44 | 45 | # Subscriptions 46 | 47 | @responses.activate 48 | def test_subscriptions_list(self): 49 | expected = { 50 | 'success': True, 51 | 'response': [ 52 | { 53 | 'subscription_id': 12345, 54 | 'plan_id': 12345, 55 | 'user_id': 12345, 56 | 'user_email': 'foo@example.com', 57 | 'marketing_consent': True, 58 | 'update_url': 'https://checkout.paddle.com/subscription/update?foo=bar&qux=baz', 59 | 'cancel_url': 'https://checkout.paddle.com/subscription/cancel?foo=bar&qux=baz', 60 | 'state': 'active', 61 | 'signup_date': '2020-01-01 20:20:20', 62 | 'last_payment': { 63 | 'amount': 0, 64 | 'currency': 'USD', 65 | 'date': '2019-11-10' 66 | }, 67 | 'linked_subscriptions': [] 68 | } 69 | ] 70 | } 71 | responses.add( 72 | method=responses.POST, 73 | url='https://vendors.paddle.com/api/2.0/subscription/users', 74 | json=expected 75 | ) 76 | self.assertListEqual( 77 | self.client.subscriptions_list(), expected['response'] 78 | ) 79 | 80 | @responses.activate 81 | def test_subscriptions_list_pagination(self): 82 | responses.add( 83 | method=responses.POST, 84 | url='https://vendors.paddle.com/api/2.0/subscription/users', 85 | json={ 86 | 'success': True, 87 | 'response': [{} for _ in range(0, 200)] 88 | } 89 | ) 90 | responses.add( 91 | method=responses.POST, 92 | url='https://vendors.paddle.com/api/2.0/subscription/users', 93 | json={ 94 | 'success': True, 95 | 'response': [{} for _ in range(0, 15)] 96 | } 97 | ) 98 | data = self.client.subscriptions_list() 99 | self.assertEqual(len(responses.calls), 2) 100 | self.assertEqual(len(data), 215) 101 | 102 | @responses.activate 103 | def test_subscriptions_cancel(self): 104 | responses.add( 105 | method=responses.POST, 106 | url='https://vendors.paddle.com/api/2.0/subscription/users_cancel', 107 | json={ 108 | 'success': True, 109 | } 110 | ) 111 | self.assertEqual(self.client.subscriptions_cancel(123), None) 112 | -------------------------------------------------------------------------------- /tests/helpers.py: -------------------------------------------------------------------------------- 1 | from functools import wraps 2 | from unittest.mock import patch 3 | 4 | 5 | def mocked_webhook_signature_is_valid(*args): 6 | return True 7 | 8 | 9 | def disable_webhook_verification(f): 10 | @wraps(f) 11 | @patch('django_paddle.views.webhook_signature_is_valid', new=mocked_webhook_signature_is_valid) 12 | def wrapper(*args, **kwds): 13 | return f(*args, **kwds) 14 | return wrapper 15 | -------------------------------------------------------------------------------- /tests/models/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kennell/django-paddle/b74ffea4c84d652ee0a8066a33762818c7f69d00/tests/models/__init__.py -------------------------------------------------------------------------------- /tests/models/test_paddle_plan.py: -------------------------------------------------------------------------------- 1 | import responses 2 | from django.test import TestCase 3 | from django_paddle.models import PaddlePlan, PaddleInitialPrice, PaddleRecurringPrice 4 | 5 | 6 | class TestPaddlePlan(TestCase): 7 | 8 | def setUp(self): 9 | self.plan = PaddlePlan.objects.create( 10 | id=123, 11 | name='Plan Foo', 12 | billing_type='month', 13 | billing_period=1, 14 | trial_days=14 15 | ) 16 | 17 | def test_initial_price_in(self): 18 | PaddleInitialPrice.objects.create( 19 | plan=self.plan, 20 | currency='USD', 21 | amount='10.000' 22 | ) 23 | self.assertEqual(self.plan.initial_price_in('USD'), '10.000') 24 | 25 | def test_recurring_price_in(self): 26 | PaddleRecurringPrice.objects.create( 27 | plan=self.plan, 28 | currency='USD', 29 | amount='10.000' 30 | ) 31 | self.assertEqual(self.plan.recurring_price_in('USD'), '10.000') 32 | 33 | def test_cancel(self): 34 | # TBI 35 | self.assertTrue(True) 36 | 37 | def test_pause(self): 38 | # TBI 39 | self.assertTrue(True) 40 | 41 | def test_unpause(self): 42 | # TBI 43 | self.assertTrue(True) 44 | 45 | 46 | class TestPaddlePlanSync(TestCase): 47 | 48 | @responses.activate 49 | def test_sync_create(self): 50 | expected = { 51 | 'success': True, 52 | 'response': [ 53 | { 54 | 'id': 12345, 55 | 'name': 'Foo', 56 | 'billing_type': 'month', 57 | 'billing_period': 1, 58 | 'trial_days': 14, 59 | 'initial_price': { 60 | 'USD': '10.00', 61 | 'EUR': '10.00' 62 | }, 63 | 'recurring_price': { 64 | 'USD': '10.00', 65 | 'EUR': '10.00' 66 | } 67 | } 68 | ] 69 | } 70 | responses.add( 71 | method=responses.POST, 72 | url='https://vendors.paddle.com/api/2.0/subscription/plans', 73 | json=expected 74 | ) 75 | PaddlePlan.sync() 76 | self.assertEqual(PaddlePlan.objects.count(), 1) 77 | self.assertEqual(PaddleInitialPrice.objects.count(), 2) 78 | self.assertEqual(PaddleRecurringPrice.objects.count(), 2) 79 | 80 | @responses.activate 81 | def test_sync_update(self): 82 | plan = PaddlePlan.objects.create( 83 | id=12345, 84 | name='Foo', 85 | billing_type='month', 86 | billing_period=1, 87 | trial_days=14 88 | ) 89 | plan.initial_prices.create( 90 | currency='USD', 91 | amount='5.00' 92 | ) 93 | plan.recurring_prices.create( 94 | currency='USD', 95 | amount='5.00' 96 | ) 97 | expected = { 98 | 'success': True, 99 | 'response': [ 100 | { 101 | 'id': 12345, 102 | 'name': 'Bar', 103 | 'billing_type': 'month', 104 | 'billing_period': 1, 105 | 'trial_days': 14, 106 | 'initial_price': { 107 | 'USD': '10.00', 108 | 'EUR': '10.00' 109 | }, 110 | 'recurring_price': { 111 | 'USD': '10.00', 112 | 'EUR': '10.00' 113 | } 114 | } 115 | ] 116 | } 117 | responses.add( 118 | method=responses.POST, 119 | url='https://vendors.paddle.com/api/2.0/subscription/plans', 120 | json=expected 121 | ) 122 | PaddlePlan.sync() 123 | plan.refresh_from_db() 124 | self.assertEqual(plan.name, 'Bar') 125 | self.assertEqual(plan.initial_price_in('USD'), '10.00') 126 | self.assertEqual(plan.initial_price_in('EUR'), '10.00') 127 | self.assertEqual(plan.recurring_price_in('USD'), '10.00') 128 | self.assertEqual(plan.recurring_price_in('EUR'), '10.00') 129 | -------------------------------------------------------------------------------- /tests/models/test_paddle_subscription.py: -------------------------------------------------------------------------------- 1 | import responses 2 | from datetime import timedelta 3 | from django.test import TestCase 4 | from django_paddle.models import PaddleSubscription, PaddlePlan 5 | from django.utils import timezone 6 | 7 | 8 | class TestPaddleSubscription(TestCase): 9 | 10 | def setUp(self): 11 | self.subscription = PaddleSubscription.objects.create( 12 | id=12345, 13 | plan=PaddlePlan.objects.create( 14 | id=123, 15 | name='Plan Foo', 16 | billing_type='month', 17 | billing_period=1, 18 | trial_days=14 19 | ), 20 | user_id=12345, 21 | user_email='foo@example.com', 22 | marketing_consent=True, 23 | update_url='https://checkout.paddle.com/subscription/update?foo=bar&qux=baz', 24 | cancel_url='https://checkout.paddle.com/subscription/cancel?foo=bar&qux=baz', 25 | state='active', 26 | signup_date='2020-01-01 20:20:20', 27 | cancellation_effective_date=None 28 | ) 29 | 30 | @responses.activate 31 | def test_subscription_cancel(self): 32 | responses.add( 33 | method=responses.POST, 34 | url='https://vendors.paddle.com/api/2.0/subscription/users_cancel', 35 | json={ 36 | 'success': True 37 | } 38 | ) 39 | self.subscription.cancel() 40 | self.subscription.refresh_from_db() 41 | self.assertEqual(self.subscription.state, 'deleted') 42 | 43 | @responses.activate 44 | def test_subscription_pause(self): 45 | # TBI 46 | self.assertTrue(True) 47 | 48 | @responses.activate 49 | def test_subscription_unpause(self): 50 | # TBI 51 | self.assertTrue(True) 52 | 53 | 54 | class TestPaddleSubscriptionIsCanceled(TestCase): 55 | 56 | """ 57 | Tests the is_canceled property 58 | """ 59 | 60 | def setUp(self): 61 | self.subscription = PaddleSubscription.objects.create( 62 | id=12345, 63 | plan=PaddlePlan.objects.create( 64 | id=123, 65 | name='Plan Foo', 66 | billing_type='month', 67 | billing_period=1, 68 | trial_days=14 69 | ), 70 | user_id=12345, 71 | user_email='foo@example.com', 72 | marketing_consent=True, 73 | update_url='https://checkout.paddle.com/subscription/update?foo=bar&qux=baz', 74 | cancel_url='https://checkout.paddle.com/subscription/cancel?foo=bar&qux=baz', 75 | state='active', 76 | signup_date='2020-01-01 20:20:20', 77 | cancellation_effective_date=None 78 | ) 79 | 80 | def test_is_canceled_cancellation_effective_date_is_null(self): 81 | self.subscription.cancellation_effective_date = None 82 | self.subscription.save() 83 | self.assertFalse(self.subscription.is_canceled) 84 | 85 | def test_is_canceled_cancellation_effective_date_is_set(self): 86 | self.subscription.cancellation_effective_date = timezone.now() 87 | self.subscription.save() 88 | self.assertTrue(self.subscription.is_canceled) 89 | 90 | 91 | class TestPaddleSubscriptionIsActive(TestCase): 92 | 93 | """ 94 | Tests the is_active property 95 | """ 96 | 97 | def setUp(self): 98 | self.subscription = PaddleSubscription.objects.create( 99 | id=12345, 100 | plan=PaddlePlan.objects.create( 101 | id=123, 102 | name='Plan Foo', 103 | billing_type='month', 104 | billing_period=1, 105 | trial_days=14 106 | ), 107 | user_id=12345, 108 | user_email='foo@example.com', 109 | marketing_consent=True, 110 | update_url='https://checkout.paddle.com/subscription/update?foo=bar&qux=baz', 111 | cancel_url='https://checkout.paddle.com/subscription/cancel?foo=bar&qux=baz', 112 | state='active', 113 | signup_date='2020-01-01 20:20:20', 114 | cancellation_effective_date=None 115 | ) 116 | 117 | def test_is_active(self): 118 | self.subscription.state = 'active' 119 | self.subscription.save() 120 | self.assertTrue(self.subscription.is_active) 121 | 122 | def test_is_active_deleted_no_cancellation_date(self): 123 | self.subscription.state = 'deleted' 124 | self.subscription.cancellation_effective_date = None 125 | self.subscription.save() 126 | self.assertFalse(self.subscription.is_active) 127 | 128 | def test_is_active_cancellation_date_in_future(self): 129 | self.subscription.state = 'deleted' 130 | self.subscription.cancellation_effective_date = timezone.now() + timedelta(days=3) 131 | self.subscription.save() 132 | self.assertTrue(self.subscription.is_active) 133 | 134 | def test_is_active_cancellation_date_in_past(self): 135 | self.subscription.state = 'deleted' 136 | self.subscription.cancellation_effective_date = timezone.now() - timedelta(days=3) 137 | self.subscription.save() 138 | self.assertFalse(self.subscription.is_active) -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | SECRET_KEY = 'fake-key' 2 | 3 | ROOT_URLCONF = 'tests.urls' 4 | 5 | DATABASES = { 6 | 'default': { 7 | 'ENGINE': 'django.db.backends.sqlite3' 8 | } 9 | } 10 | 11 | INSTALLED_APPS = [ 12 | 'django.contrib.auth', 13 | 'django.contrib.contenttypes', 14 | 'django_paddle' 15 | ] 16 | 17 | PADDLE_VENDOR_ID = '12345' 18 | PADDLE_AUTH_CODE = 'very-secret-auth-code' 19 | PADDLE_PUBLIC_KEY = """-----BEGIN PUBLIC KEY----- 20 | MIGeMA0GCSqGSIb3DQEBAQUAA4GMADCBiAKBgFRcEWH8FJB0UnbD7Owhl6anraQS 21 | /5xrqyPyLkjR3Xb9/WsvrA1eP3ePg+vKdypMD+1puGg2/ler8aDi1OmvWC031ERs 22 | 06LHL628aVMvu1n6nZyKtvoFJpYTxBE804Evf6FSH5C+oba2BH6fEW9BxtraK7Co 23 | SKoHy0wFWqzUHsBBAgMBAAE= 24 | -----END PUBLIC KEY-----""" 25 | PADDLE_ACCOUNT_MODEL='auth.User' -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path, include 2 | 3 | urlpatterns = [ 4 | path("", include('django_paddle.urls')) 5 | ] 6 | -------------------------------------------------------------------------------- /tests/webhooks/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kennell/django-paddle/b74ffea4c84d652ee0a8066a33762818c7f69d00/tests/webhooks/__init__.py -------------------------------------------------------------------------------- /tests/webhooks/test_signals.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import MagicMock 2 | from django.test import Client, TestCase 3 | from django.urls import reverse 4 | from django_paddle import signals 5 | from django_paddle.views import Webhook 6 | from tests.helpers import disable_webhook_verification 7 | 8 | 9 | class TestWebhookSignals(TestCase): 10 | 11 | def setUp(self): 12 | self.client = Client() 13 | self.path = reverse('django_paddle_webhook') 14 | 15 | @disable_webhook_verification 16 | def test_subscription_created(self): 17 | receiver = MagicMock() 18 | signals.subscription_created.connect(receiver, sender=Webhook) 19 | rsp = self.client.post( 20 | path=self.path, 21 | data={ 22 | 'alert_name': 'subscription_created', 23 | 'p_signature': 'cXdlcnR5' 24 | } 25 | ) 26 | 27 | self.assertEqual(rsp.status_code, 200) 28 | receiver.assert_called_once() 29 | 30 | --------------------------------------------------------------------------------