university-subscriptions 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- university_subscriptions-0.1.0/.gitignore +42 -0
- university_subscriptions-0.1.0/LICENSE +21 -0
- university_subscriptions-0.1.0/PKG-INFO +94 -0
- university_subscriptions-0.1.0/README.md +71 -0
- university_subscriptions-0.1.0/pyproject.toml +41 -0
- university_subscriptions-0.1.0/src/university/subscriptions/__init__.py +3 -0
- university_subscriptions-0.1.0/src/university/subscriptions/admin.py +19 -0
- university_subscriptions-0.1.0/src/university/subscriptions/api/__init__.py +0 -0
- university_subscriptions-0.1.0/src/university/subscriptions/api/v1/__init__.py +0 -0
- university_subscriptions-0.1.0/src/university/subscriptions/api/v1/serializers.py +104 -0
- university_subscriptions-0.1.0/src/university/subscriptions/api/v1/urls.py +13 -0
- university_subscriptions-0.1.0/src/university/subscriptions/api/v1/views.py +66 -0
- university_subscriptions-0.1.0/src/university/subscriptions/apps.py +34 -0
- university_subscriptions-0.1.0/src/university/subscriptions/conf.py +58 -0
- university_subscriptions-0.1.0/src/university/subscriptions/events.py +10 -0
- university_subscriptions-0.1.0/src/university/subscriptions/integrations.py +49 -0
- university_subscriptions-0.1.0/src/university/subscriptions/migrations/0001_initial.py +61 -0
- university_subscriptions-0.1.0/src/university/subscriptions/migrations/__init__.py +0 -0
- university_subscriptions-0.1.0/src/university/subscriptions/models.py +64 -0
- university_subscriptions-0.1.0/src/university/subscriptions/payables.py +45 -0
- university_subscriptions-0.1.0/src/university/subscriptions/services.py +210 -0
- university_subscriptions-0.1.0/tests/__init__.py +0 -0
- university_subscriptions-0.1.0/tests/conftest.py +1 -0
- university_subscriptions-0.1.0/tests/settings.py +14 -0
- university_subscriptions-0.1.0/tests/test_subscriptions.py +185 -0
- university_subscriptions-0.1.0/tests/urls.py +3 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
.eggs/
|
|
6
|
+
build/
|
|
7
|
+
dist/
|
|
8
|
+
.venv/
|
|
9
|
+
venv/
|
|
10
|
+
.nox/
|
|
11
|
+
.tox/
|
|
12
|
+
.pytest_cache/
|
|
13
|
+
.mypy_cache/
|
|
14
|
+
.ruff_cache/
|
|
15
|
+
.coverage
|
|
16
|
+
coverage.xml
|
|
17
|
+
htmlcov/
|
|
18
|
+
|
|
19
|
+
# Django
|
|
20
|
+
*.sqlite3
|
|
21
|
+
# Local user uploads (never the university.media source package).
|
|
22
|
+
/media/
|
|
23
|
+
/private-media/
|
|
24
|
+
example_project/media/
|
|
25
|
+
example_project/private-media/
|
|
26
|
+
staticfiles/
|
|
27
|
+
|
|
28
|
+
# Secrets — never commit
|
|
29
|
+
.env
|
|
30
|
+
.env.*
|
|
31
|
+
!.env.example
|
|
32
|
+
*.pem
|
|
33
|
+
*.key
|
|
34
|
+
|
|
35
|
+
# Editors / OS
|
|
36
|
+
.idea/
|
|
37
|
+
.vscode/
|
|
38
|
+
.DS_Store
|
|
39
|
+
Thumbs.db
|
|
40
|
+
|
|
41
|
+
# GistLoop is a separate project with its own repository.
|
|
42
|
+
/GistLoop/
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) [YEAR] [COPYRIGHT HOLDER]
|
|
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.
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: university-subscriptions
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: University Subscriptions: Premium plans, paid periods and entitlements other modules can check.
|
|
5
|
+
Author-email: Ifeanyi Stanley <nnamaniifeanyi10@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Classifier: Development Status :: 2 - Pre-Alpha
|
|
9
|
+
Classifier: Framework :: Django
|
|
10
|
+
Classifier: Framework :: Django :: 5.2
|
|
11
|
+
Classifier: Framework :: Django :: 6.0
|
|
12
|
+
Classifier: Framework :: Django :: 6.1
|
|
13
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
14
|
+
Classifier: Typing :: Typed
|
|
15
|
+
Requires-Python: >=3.11
|
|
16
|
+
Requires-Dist: university-core<0.2,>=0.1.1
|
|
17
|
+
Provides-Extra: test
|
|
18
|
+
Requires-Dist: drf-spectacular>=0.27; extra == 'test'
|
|
19
|
+
Requires-Dist: pytest-cov>=5; extra == 'test'
|
|
20
|
+
Requires-Dist: pytest-django>=4.8; extra == 'test'
|
|
21
|
+
Requires-Dist: pytest>=8; extra == 'test'
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# university-subscriptions
|
|
25
|
+
|
|
26
|
+
Premium plans, paid periods and entitlements other modules can check.
|
|
27
|
+
|
|
28
|
+
An optional **level-two** module of the University platform.
|
|
29
|
+
|
|
30
|
+
## Installation
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install university-subscriptions university-payments
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
INSTALLED_APPS = [
|
|
38
|
+
# ...
|
|
39
|
+
*CORE_APPS,
|
|
40
|
+
"university.payments",
|
|
41
|
+
"university.subscriptions",
|
|
42
|
+
]
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Run `python manage.py migrate`. The API is mounted under `/api/v1/subscriptions/`.
|
|
46
|
+
|
|
47
|
+
## How it works
|
|
48
|
+
|
|
49
|
+
1. `GET plans/` lists the plans, prices and what each includes.
|
|
50
|
+
2. `POST me/` with `{plan, interval, currency}` chooses a plan. The response's `payment_due`
|
|
51
|
+
is passed to `POST /api/v1/payments/checkout/` (purpose `subscriptions.period`).
|
|
52
|
+
3. When the payment succeeds the subscription becomes `active` for one interval.
|
|
53
|
+
4. Renewals are paid by the member (no stored cards) from `RENEWAL_WINDOW_DAYS` before the
|
|
54
|
+
period ends. A reminder goes out `REMINDER_DAYS` before, and entitlements continue for
|
|
55
|
+
`GRACE_DAYS` after an unpaid period ends, then the subscription expires.
|
|
56
|
+
5. `POST me/cancel/` stops renewing; the plan lasts until the paid period ends.
|
|
57
|
+
|
|
58
|
+
Run the `university.subscriptions.sweep` task (or call `services.sweep()`) every hour.
|
|
59
|
+
|
|
60
|
+
## Entitlements
|
|
61
|
+
|
|
62
|
+
Plans grant entitlement keys. Any module can check them through Core, without depending on
|
|
63
|
+
this package:
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
from university.core.entitlements import has_entitlement
|
|
67
|
+
|
|
68
|
+
if has_entitlement(request.user, "creator.analytics_pro"):
|
|
69
|
+
...
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
`premium.ad_free` hides adverts in university-promotions; `premium.badge` adds `premium: true`
|
|
73
|
+
to public profiles.
|
|
74
|
+
|
|
75
|
+
## Configuration
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
UNIVERSITY = {
|
|
79
|
+
"SUBSCRIPTIONS": {
|
|
80
|
+
"PLANS": {
|
|
81
|
+
"plus": {
|
|
82
|
+
"name": "Plus",
|
|
83
|
+
"description": "No adverts and a profile badge.",
|
|
84
|
+
"prices": {"month": {"NGN": 150000}, "year": {"NGN": 1500000}},
|
|
85
|
+
"entitlements": ["premium.ad_free", "premium.badge"],
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
"GRACE_DAYS": 3,
|
|
89
|
+
},
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
A double payment, or a payment for the wrong amount, is refused and refunded by
|
|
94
|
+
university-payments. A refunded period is taken back off the subscription.
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# university-subscriptions
|
|
2
|
+
|
|
3
|
+
Premium plans, paid periods and entitlements other modules can check.
|
|
4
|
+
|
|
5
|
+
An optional **level-two** module of the University platform.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install university-subscriptions university-payments
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
INSTALLED_APPS = [
|
|
15
|
+
# ...
|
|
16
|
+
*CORE_APPS,
|
|
17
|
+
"university.payments",
|
|
18
|
+
"university.subscriptions",
|
|
19
|
+
]
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Run `python manage.py migrate`. The API is mounted under `/api/v1/subscriptions/`.
|
|
23
|
+
|
|
24
|
+
## How it works
|
|
25
|
+
|
|
26
|
+
1. `GET plans/` lists the plans, prices and what each includes.
|
|
27
|
+
2. `POST me/` with `{plan, interval, currency}` chooses a plan. The response's `payment_due`
|
|
28
|
+
is passed to `POST /api/v1/payments/checkout/` (purpose `subscriptions.period`).
|
|
29
|
+
3. When the payment succeeds the subscription becomes `active` for one interval.
|
|
30
|
+
4. Renewals are paid by the member (no stored cards) from `RENEWAL_WINDOW_DAYS` before the
|
|
31
|
+
period ends. A reminder goes out `REMINDER_DAYS` before, and entitlements continue for
|
|
32
|
+
`GRACE_DAYS` after an unpaid period ends, then the subscription expires.
|
|
33
|
+
5. `POST me/cancel/` stops renewing; the plan lasts until the paid period ends.
|
|
34
|
+
|
|
35
|
+
Run the `university.subscriptions.sweep` task (or call `services.sweep()`) every hour.
|
|
36
|
+
|
|
37
|
+
## Entitlements
|
|
38
|
+
|
|
39
|
+
Plans grant entitlement keys. Any module can check them through Core, without depending on
|
|
40
|
+
this package:
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
from university.core.entitlements import has_entitlement
|
|
44
|
+
|
|
45
|
+
if has_entitlement(request.user, "creator.analytics_pro"):
|
|
46
|
+
...
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
`premium.ad_free` hides adverts in university-promotions; `premium.badge` adds `premium: true`
|
|
50
|
+
to public profiles.
|
|
51
|
+
|
|
52
|
+
## Configuration
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
UNIVERSITY = {
|
|
56
|
+
"SUBSCRIPTIONS": {
|
|
57
|
+
"PLANS": {
|
|
58
|
+
"plus": {
|
|
59
|
+
"name": "Plus",
|
|
60
|
+
"description": "No adverts and a profile badge.",
|
|
61
|
+
"prices": {"month": {"NGN": 150000}, "year": {"NGN": 1500000}},
|
|
62
|
+
"entitlements": ["premium.ad_free", "premium.badge"],
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
"GRACE_DAYS": 3,
|
|
66
|
+
},
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
A double payment, or a payment for the wrong amount, is refused and refunded by
|
|
71
|
+
university-payments. A refunded period is taken back off the subscription.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.24"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "university-subscriptions"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "University Subscriptions: Premium plans, paid periods and entitlements other modules can check."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.11"
|
|
13
|
+
authors = [{ name = "Ifeanyi Stanley", email = "nnamaniifeanyi10@gmail.com" }]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 2 - Pre-Alpha",
|
|
16
|
+
"Framework :: Django",
|
|
17
|
+
"Framework :: Django :: 5.2",
|
|
18
|
+
"Framework :: Django :: 6.0",
|
|
19
|
+
"Framework :: Django :: 6.1",
|
|
20
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
21
|
+
"Typing :: Typed",
|
|
22
|
+
]
|
|
23
|
+
# Level-two modules depend on Core only. Depend on another module only when
|
|
24
|
+
# unavoidable, and never in a cycle.
|
|
25
|
+
dependencies = ["university-core>=0.1.1,<0.2"]
|
|
26
|
+
|
|
27
|
+
[project.optional-dependencies]
|
|
28
|
+
test = ["pytest>=8", "pytest-django>=4.8", "pytest-cov>=5", "drf-spectacular>=0.27"]
|
|
29
|
+
|
|
30
|
+
[tool.hatch.version]
|
|
31
|
+
path = "src/university/subscriptions/__init__.py"
|
|
32
|
+
|
|
33
|
+
[tool.hatch.build.targets.wheel]
|
|
34
|
+
packages = ["src/university"]
|
|
35
|
+
|
|
36
|
+
[tool.pytest.ini_options]
|
|
37
|
+
DJANGO_SETTINGS_MODULE = "tests.settings"
|
|
38
|
+
pythonpath = ["."]
|
|
39
|
+
testpaths = ["tests"]
|
|
40
|
+
addopts = "-ra --strict-markers"
|
|
41
|
+
markers = ["security: security regression tests"]
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from django.contrib import admin
|
|
2
|
+
|
|
3
|
+
from university.subscriptions.models import Period, Subscription
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class PeriodInline(admin.TabularInline):
|
|
7
|
+
model = Period
|
|
8
|
+
extra = 0
|
|
9
|
+
can_delete = False
|
|
10
|
+
readonly_fields = ("starts_at", "ends_at", "amount_minor", "currency", "payment_reference")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@admin.register(Subscription)
|
|
14
|
+
class SubscriptionAdmin(admin.ModelAdmin):
|
|
15
|
+
list_display = ("user", "plan", "interval", "status", "current_period_end", "cancel_at_period_end")
|
|
16
|
+
list_filter = ("plan", "status", "interval")
|
|
17
|
+
search_fields = ("user__handle", "user__email")
|
|
18
|
+
readonly_fields = ("user", "current_period_start", "current_period_end", "reminded_for")
|
|
19
|
+
inlines = [PeriodInline]
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from rest_framework import serializers
|
|
6
|
+
|
|
7
|
+
from university.core.api.schema import extend_schema_field
|
|
8
|
+
from university.subscriptions.conf import plans, subscriptions_settings
|
|
9
|
+
from university.subscriptions.models import Interval, Period, Subscription
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SubscriptionEntitlementSerializer(serializers.Serializer):
|
|
13
|
+
key = serializers.CharField()
|
|
14
|
+
label = serializers.CharField()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SubscriptionPriceSerializer(serializers.Serializer):
|
|
18
|
+
interval = serializers.ChoiceField(choices=Interval.choices)
|
|
19
|
+
currency = serializers.CharField()
|
|
20
|
+
amount_minor = serializers.IntegerField()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class SubscriptionPlanSerializer(serializers.Serializer):
|
|
24
|
+
key = serializers.CharField()
|
|
25
|
+
name = serializers.CharField()
|
|
26
|
+
description = serializers.CharField()
|
|
27
|
+
prices = SubscriptionPriceSerializer(many=True)
|
|
28
|
+
entitlements = SubscriptionEntitlementSerializer(many=True)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def plan_data(key: str, plan: dict[str, Any]) -> dict[str, Any]:
|
|
32
|
+
labels = subscriptions_settings.get("ENTITLEMENT_LABELS") or {}
|
|
33
|
+
return {
|
|
34
|
+
"key": key,
|
|
35
|
+
"name": plan.get("name", key),
|
|
36
|
+
"description": plan.get("description", ""),
|
|
37
|
+
"prices": [
|
|
38
|
+
{"interval": interval, "currency": currency, "amount_minor": int(amount)}
|
|
39
|
+
for interval, by_currency in (plan.get("prices") or {}).items()
|
|
40
|
+
for currency, amount in by_currency.items()
|
|
41
|
+
],
|
|
42
|
+
"entitlements": [{"key": k, "label": labels.get(k, k)} for k in plan.get("entitlements", [])],
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class SubscriptionPeriodSerializer(serializers.ModelSerializer):
|
|
47
|
+
class Meta:
|
|
48
|
+
model = Period
|
|
49
|
+
fields = ("starts_at", "ends_at", "amount_minor", "currency", "payment_reference")
|
|
50
|
+
read_only_fields = fields
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class SubscriptionPaymentHintSerializer(serializers.Serializer):
|
|
54
|
+
"""Pass to POST /payments/checkout/ to pay what's due."""
|
|
55
|
+
|
|
56
|
+
purpose = serializers.CharField()
|
|
57
|
+
object_id = serializers.CharField()
|
|
58
|
+
amount_minor = serializers.IntegerField()
|
|
59
|
+
currency = serializers.CharField()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class SubscriptionSerializer(serializers.ModelSerializer):
|
|
63
|
+
plan_name = serializers.SerializerMethodField()
|
|
64
|
+
payment_due = serializers.SerializerMethodField()
|
|
65
|
+
periods = serializers.SerializerMethodField()
|
|
66
|
+
|
|
67
|
+
class Meta:
|
|
68
|
+
model = Subscription
|
|
69
|
+
fields = (
|
|
70
|
+
"id", "plan", "plan_name", "interval", "currency", "status", "current_period_start", "current_period_end",
|
|
71
|
+
"cancel_at_period_end", "payment_due", "periods", "created_at",
|
|
72
|
+
) # fmt: skip
|
|
73
|
+
read_only_fields = fields
|
|
74
|
+
|
|
75
|
+
def get_plan_name(self, obj: Subscription) -> str:
|
|
76
|
+
return str(plans().get(obj.plan, {}).get("name", obj.plan))
|
|
77
|
+
|
|
78
|
+
@extend_schema_field(SubscriptionPaymentHintSerializer(allow_null=True))
|
|
79
|
+
def get_payment_due(self, obj: Subscription) -> dict[str, Any] | None:
|
|
80
|
+
from university.subscriptions.services import PURPOSE, amount_due
|
|
81
|
+
|
|
82
|
+
due = amount_due(obj)
|
|
83
|
+
if due is None:
|
|
84
|
+
return None
|
|
85
|
+
return {"purpose": PURPOSE, "object_id": str(obj.pk), "amount_minor": due, "currency": obj.currency}
|
|
86
|
+
|
|
87
|
+
@extend_schema_field(SubscriptionPeriodSerializer(many=True))
|
|
88
|
+
def get_periods(self, obj: Subscription) -> list[dict[str, Any]]:
|
|
89
|
+
return SubscriptionPeriodSerializer(obj.periods.all()[:12], many=True).data
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class MySubscriptionSerializer(serializers.Serializer):
|
|
93
|
+
subscription = SubscriptionSerializer(allow_null=True)
|
|
94
|
+
entitlements = serializers.ListField(child=serializers.CharField())
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class SubscribeSerializer(serializers.Serializer):
|
|
98
|
+
plan = serializers.CharField(max_length=32)
|
|
99
|
+
interval = serializers.ChoiceField(choices=Interval.choices)
|
|
100
|
+
currency = serializers.CharField(max_length=3, default="NGN")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class CancelSerializer(serializers.Serializer):
|
|
104
|
+
cancel = serializers.BooleanField(default=True, help_text="False to keep renewing (undo a cancellation).")
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from django.urls import path
|
|
4
|
+
|
|
5
|
+
from university.subscriptions.api.v1 import views
|
|
6
|
+
|
|
7
|
+
app_name = "subscriptions"
|
|
8
|
+
|
|
9
|
+
urlpatterns = [
|
|
10
|
+
path("plans/", views.PlanListView.as_view(), name="plan-list"),
|
|
11
|
+
path("me/", views.MySubscriptionView.as_view(), name="me"),
|
|
12
|
+
path("me/cancel/", views.CancelView.as_view(), name="me-cancel"),
|
|
13
|
+
]
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from rest_framework import permissions, status
|
|
4
|
+
from rest_framework.request import Request
|
|
5
|
+
from rest_framework.response import Response
|
|
6
|
+
from rest_framework.views import APIView
|
|
7
|
+
|
|
8
|
+
from university.core.api.schema import extend_schema
|
|
9
|
+
from university.core.entitlements import entitlements
|
|
10
|
+
from university.subscriptions import services
|
|
11
|
+
from university.subscriptions.api.v1 import serializers as s
|
|
12
|
+
from university.subscriptions.conf import plans
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class PlanListView(APIView):
|
|
16
|
+
permission_classes = [permissions.AllowAny]
|
|
17
|
+
|
|
18
|
+
@extend_schema(responses=s.SubscriptionPlanSerializer(many=True))
|
|
19
|
+
def get(self, request: Request) -> Response:
|
|
20
|
+
return Response([s.plan_data(key, plan) for key, plan in plans().items()])
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class MySubscriptionView(APIView):
|
|
24
|
+
permission_classes = [permissions.IsAuthenticated]
|
|
25
|
+
|
|
26
|
+
def _body(self, request: Request) -> dict:
|
|
27
|
+
sub = services.current(request.user)
|
|
28
|
+
return {
|
|
29
|
+
"subscription": s.SubscriptionSerializer(sub).data if sub else None,
|
|
30
|
+
"entitlements": sorted(entitlements(request.user)),
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
@extend_schema(responses=s.MySubscriptionSerializer)
|
|
34
|
+
def get(self, request: Request) -> Response:
|
|
35
|
+
return Response(self._body(request))
|
|
36
|
+
|
|
37
|
+
@extend_schema(
|
|
38
|
+
request=s.SubscribeSerializer,
|
|
39
|
+
responses={201: s.SubscriptionSerializer},
|
|
40
|
+
description="Choose a plan. Then pay `payment_due` through POST /payments/checkout/.",
|
|
41
|
+
)
|
|
42
|
+
def post(self, request: Request) -> Response:
|
|
43
|
+
serializer = s.SubscribeSerializer(data=request.data)
|
|
44
|
+
serializer.is_valid(raise_exception=True)
|
|
45
|
+
sub = services.subscribe(user=request.user, **serializer.validated_data)
|
|
46
|
+
return Response(s.SubscriptionSerializer(sub).data, status=status.HTTP_201_CREATED)
|
|
47
|
+
|
|
48
|
+
@extend_schema(responses={204: None}, description="Drop a subscription that hasn't been paid for yet.")
|
|
49
|
+
def delete(self, request: Request) -> Response:
|
|
50
|
+
services.abandon(user=request.user)
|
|
51
|
+
return Response(status=status.HTTP_204_NO_CONTENT)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class CancelView(APIView):
|
|
55
|
+
permission_classes = [permissions.IsAuthenticated]
|
|
56
|
+
|
|
57
|
+
@extend_schema(
|
|
58
|
+
request=s.CancelSerializer,
|
|
59
|
+
responses=s.SubscriptionSerializer,
|
|
60
|
+
description="Stop renewing: the plan stays until the paid period ends. Send cancel=false to undo.",
|
|
61
|
+
)
|
|
62
|
+
def post(self, request: Request) -> Response:
|
|
63
|
+
serializer = s.CancelSerializer(data=request.data)
|
|
64
|
+
serializer.is_valid(raise_exception=True)
|
|
65
|
+
sub = services.set_cancel_at_period_end(user=request.user, cancel=serializer.validated_data["cancel"])
|
|
66
|
+
return Response(s.SubscriptionSerializer(sub).data)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from django.utils.translation import gettext_lazy as _
|
|
6
|
+
|
|
7
|
+
from university.core.hooks import Hook, HookRegistry
|
|
8
|
+
from university.core.modules import UniversityAppConfig
|
|
9
|
+
from university.subscriptions import __version__
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SubscriptionsConfig(UniversityAppConfig):
|
|
13
|
+
name = "university.subscriptions"
|
|
14
|
+
label = "university_subscriptions"
|
|
15
|
+
verbose_name = _("University Subscriptions")
|
|
16
|
+
module_name = "subscriptions"
|
|
17
|
+
module_version = __version__
|
|
18
|
+
module_requires = ("core", "users", "audit")
|
|
19
|
+
settings_namespace = "SUBSCRIPTIONS"
|
|
20
|
+
api_prefix = "subscriptions/"
|
|
21
|
+
api_urlconfs = {"v1": "university.subscriptions.api.v1.urls"}
|
|
22
|
+
|
|
23
|
+
def get_payable_adapters(self) -> list[Any]:
|
|
24
|
+
from university.subscriptions.payables import PeriodPayable
|
|
25
|
+
|
|
26
|
+
return [PeriodPayable()]
|
|
27
|
+
|
|
28
|
+
def register_hooks(self, hooks: HookRegistry) -> None:
|
|
29
|
+
from university.subscriptions import integrations, services
|
|
30
|
+
|
|
31
|
+
hooks.register(Hook.USER_ENTITLEMENTS, services.user_entitlements)
|
|
32
|
+
hooks.register(Hook.USER_PUBLIC_PROFILE, integrations.public_profile)
|
|
33
|
+
hooks.register(Hook.USER_EXPORT_DATA, integrations.export_data)
|
|
34
|
+
hooks.register(Hook.USER_PURGE, integrations.purge)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Settings for subscriptions (``UNIVERSITY["SUBSCRIPTIONS"]``)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from university.core.conf import ModuleSettings
|
|
8
|
+
|
|
9
|
+
DEFAULTS: dict[str, Any] = {
|
|
10
|
+
# Plan key -> definition. Prices are per interval, per currency, in minor units. A plan's
|
|
11
|
+
# entitlements are keys other modules check with university.core.entitlements.
|
|
12
|
+
"PLANS": {
|
|
13
|
+
"plus": {
|
|
14
|
+
"name": "Plus",
|
|
15
|
+
"description": "No adverts, a profile badge and more control over your inbox.",
|
|
16
|
+
"prices": {"month": {"NGN": 150000, "USD": 299}, "year": {"NGN": 1500000, "USD": 2999}},
|
|
17
|
+
"entitlements": ["premium.ad_free", "premium.badge", "premium.inbox_filters"],
|
|
18
|
+
},
|
|
19
|
+
"creator_pro": {
|
|
20
|
+
"name": "Creator Pro",
|
|
21
|
+
"description": "Everything in Plus, with deeper analytics and priority sponsorship placement.",
|
|
22
|
+
"prices": {"month": {"NGN": 500000, "USD": 999}, "year": {"NGN": 5000000, "USD": 9999}},
|
|
23
|
+
"entitlements": [
|
|
24
|
+
"premium.ad_free",
|
|
25
|
+
"premium.badge",
|
|
26
|
+
"premium.inbox_filters",
|
|
27
|
+
"creator.analytics_pro",
|
|
28
|
+
"creator.priority_listing",
|
|
29
|
+
],
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
# Human labels for entitlement keys, shown on the plans page.
|
|
33
|
+
"ENTITLEMENT_LABELS": {
|
|
34
|
+
"premium.ad_free": "No adverts",
|
|
35
|
+
"premium.badge": "Premium badge on your profile",
|
|
36
|
+
"premium.inbox_filters": "Anonymous inbox filters",
|
|
37
|
+
"creator.analytics_pro": "Advanced creator analytics",
|
|
38
|
+
"creator.priority_listing": "Priority in the creator directory",
|
|
39
|
+
},
|
|
40
|
+
"INTERVAL_DAYS": {"month": 30, "year": 365},
|
|
41
|
+
# A renewal can be paid this many days before the period ends.
|
|
42
|
+
"RENEWAL_WINDOW_DAYS": 7,
|
|
43
|
+
# Reminder sent this many days before the period ends.
|
|
44
|
+
"REMINDER_DAYS": 3,
|
|
45
|
+
# After a period ends unpaid, entitlements continue this long before the subscription expires.
|
|
46
|
+
"GRACE_DAYS": 3,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
subscriptions_settings = ModuleSettings("SUBSCRIPTIONS", DEFAULTS)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def plans() -> dict[str, dict[str, Any]]:
|
|
53
|
+
return dict(subscriptions_settings.get("PLANS") or {})
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def price(plan: str, interval: str, currency: str) -> int | None:
|
|
57
|
+
value = (plans().get(plan, {}).get("prices", {}).get(interval) or {}).get(currency)
|
|
58
|
+
return int(value) if value else None
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Events published by subscriptions (ids only)."""
|
|
2
|
+
|
|
3
|
+
#: {"subscription_id", "user_id", "plan"}: the first period was paid.
|
|
4
|
+
SUBSCRIPTION_ACTIVATED = "subscriptions.subscription.activated"
|
|
5
|
+
#: {"subscription_id", "user_id", "plan"}: a further period was paid.
|
|
6
|
+
SUBSCRIPTION_RENEWED = "subscriptions.subscription.renewed"
|
|
7
|
+
#: {"subscription_id", "user_id", "plan"}: the period ends soon and isn't paid yet.
|
|
8
|
+
SUBSCRIPTION_RENEWAL_DUE = "subscriptions.subscription.renewal_due"
|
|
9
|
+
#: {"subscription_id", "user_id", "plan"}: entitlements ended.
|
|
10
|
+
SUBSCRIPTION_EXPIRED = "subscriptions.subscription.expired"
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Profile badge, data export, purge and the scheduled sweep for subscriptions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from university.core.entitlements import has_entitlement
|
|
8
|
+
from university.core.tasks import task
|
|
9
|
+
from university.subscriptions.models import Subscription
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def public_profile(target: Any, viewer: Any) -> dict[str, Any]:
|
|
13
|
+
# Only the badge is public; plans and payments never are.
|
|
14
|
+
return {"premium": has_entitlement(target, "premium.badge")}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def export_data(user: Any) -> dict[str, Any]:
|
|
18
|
+
return {
|
|
19
|
+
"subscriptions": [
|
|
20
|
+
{
|
|
21
|
+
"id": s.pk,
|
|
22
|
+
"plan": s.plan,
|
|
23
|
+
"interval": s.interval,
|
|
24
|
+
"status": s.status,
|
|
25
|
+
"periods": [
|
|
26
|
+
{
|
|
27
|
+
"starts_at": p.starts_at,
|
|
28
|
+
"ends_at": p.ends_at,
|
|
29
|
+
"amount_minor": p.amount_minor,
|
|
30
|
+
"currency": p.currency,
|
|
31
|
+
}
|
|
32
|
+
for p in s.periods.all()
|
|
33
|
+
],
|
|
34
|
+
}
|
|
35
|
+
for s in Subscription.objects.filter(user=user).prefetch_related("periods")
|
|
36
|
+
]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def purge(user_id: Any) -> None:
|
|
41
|
+
# Payment records live (anonymised) in university-payments' ledger; the plan history goes.
|
|
42
|
+
Subscription.objects.filter(user_id=user_id).delete()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@task(name="university.subscriptions.sweep")
|
|
46
|
+
def sweep() -> dict[str, int]:
|
|
47
|
+
from university.subscriptions.services import sweep as run
|
|
48
|
+
|
|
49
|
+
return run()
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# Generated by Django 6.1.2 on 2026-10-28 16:41
|
|
2
|
+
|
|
3
|
+
import django.db.models.deletion
|
|
4
|
+
import django.utils.timezone
|
|
5
|
+
import university.core.ids
|
|
6
|
+
from django.conf import settings
|
|
7
|
+
from django.db import migrations, models
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Migration(migrations.Migration):
|
|
11
|
+
|
|
12
|
+
initial = True
|
|
13
|
+
|
|
14
|
+
dependencies = [
|
|
15
|
+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
operations = [
|
|
19
|
+
migrations.CreateModel(
|
|
20
|
+
name='Subscription',
|
|
21
|
+
fields=[
|
|
22
|
+
('id', models.UUIDField(default=university.core.ids.new_uuid, editable=False, primary_key=True, serialize=False)),
|
|
23
|
+
('created_at', models.DateTimeField(default=django.utils.timezone.now, editable=False, verbose_name='created at')),
|
|
24
|
+
('updated_at', models.DateTimeField(auto_now=True, verbose_name='updated at')),
|
|
25
|
+
('plan', models.CharField(max_length=32)),
|
|
26
|
+
('interval', models.CharField(choices=[('month', 'Monthly'), ('year', 'Yearly')], max_length=8)),
|
|
27
|
+
('currency', models.CharField(max_length=3)),
|
|
28
|
+
('status', models.CharField(choices=[('pending_payment', 'Awaiting first payment'), ('active', 'Active'), ('past_due', 'Renewal due (grace period)'), ('expired', 'Expired'), ('abandoned', 'Never paid')], default='pending_payment', max_length=16)),
|
|
29
|
+
('current_period_start', models.DateTimeField(blank=True, null=True)),
|
|
30
|
+
('current_period_end', models.DateTimeField(blank=True, null=True)),
|
|
31
|
+
('cancel_at_period_end', models.BooleanField(default=False)),
|
|
32
|
+
('reminded_for', models.DateTimeField(blank=True, null=True)),
|
|
33
|
+
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='subscriptions', to=settings.AUTH_USER_MODEL)),
|
|
34
|
+
],
|
|
35
|
+
),
|
|
36
|
+
migrations.CreateModel(
|
|
37
|
+
name='Period',
|
|
38
|
+
fields=[
|
|
39
|
+
('id', models.UUIDField(default=university.core.ids.new_uuid, editable=False, primary_key=True, serialize=False)),
|
|
40
|
+
('created_at', models.DateTimeField(default=django.utils.timezone.now, editable=False, verbose_name='created at')),
|
|
41
|
+
('updated_at', models.DateTimeField(auto_now=True, verbose_name='updated at')),
|
|
42
|
+
('starts_at', models.DateTimeField()),
|
|
43
|
+
('ends_at', models.DateTimeField()),
|
|
44
|
+
('amount_minor', models.PositiveBigIntegerField()),
|
|
45
|
+
('currency', models.CharField(max_length=3)),
|
|
46
|
+
('payment_reference', models.CharField(max_length=64, unique=True)),
|
|
47
|
+
('subscription', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='periods', to='university_subscriptions.subscription')),
|
|
48
|
+
],
|
|
49
|
+
options={
|
|
50
|
+
'ordering': ('-starts_at',),
|
|
51
|
+
},
|
|
52
|
+
),
|
|
53
|
+
migrations.AddIndex(
|
|
54
|
+
model_name='subscription',
|
|
55
|
+
index=models.Index(fields=['status', 'current_period_end'], name='univ_subs_status_end'),
|
|
56
|
+
),
|
|
57
|
+
migrations.AddConstraint(
|
|
58
|
+
model_name='subscription',
|
|
59
|
+
constraint=models.UniqueConstraint(condition=models.Q(('status__in', ('pending_payment', 'active', 'past_due'))), fields=('user',), name='univ_subs_one_live_per_user'),
|
|
60
|
+
),
|
|
61
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from django.conf import settings
|
|
4
|
+
from django.db import models
|
|
5
|
+
from django.db.models import Q
|
|
6
|
+
from django.utils.translation import gettext_lazy as _
|
|
7
|
+
|
|
8
|
+
from university.core.models import BaseModel
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Interval(models.TextChoices):
|
|
12
|
+
MONTH = "month", _("Monthly")
|
|
13
|
+
YEAR = "year", _("Yearly")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SubscriptionStatus(models.TextChoices):
|
|
17
|
+
PENDING_PAYMENT = "pending_payment", _("Awaiting first payment")
|
|
18
|
+
ACTIVE = "active", _("Active")
|
|
19
|
+
PAST_DUE = "past_due", _("Renewal due (grace period)")
|
|
20
|
+
EXPIRED = "expired", _("Expired")
|
|
21
|
+
ABANDONED = "abandoned", _("Never paid")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
LIVE = (SubscriptionStatus.PENDING_PAYMENT, SubscriptionStatus.ACTIVE, SubscriptionStatus.PAST_DUE)
|
|
25
|
+
ENTITLED = (SubscriptionStatus.ACTIVE, SubscriptionStatus.PAST_DUE)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Subscription(BaseModel):
|
|
29
|
+
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="subscriptions")
|
|
30
|
+
plan = models.CharField(max_length=32)
|
|
31
|
+
interval = models.CharField(max_length=8, choices=Interval.choices)
|
|
32
|
+
currency = models.CharField(max_length=3)
|
|
33
|
+
status = models.CharField(
|
|
34
|
+
max_length=16, choices=SubscriptionStatus.choices, default=SubscriptionStatus.PENDING_PAYMENT
|
|
35
|
+
)
|
|
36
|
+
current_period_start = models.DateTimeField(null=True, blank=True)
|
|
37
|
+
current_period_end = models.DateTimeField(null=True, blank=True)
|
|
38
|
+
#: The member doesn't intend to renew: no reminders; it simply ends.
|
|
39
|
+
cancel_at_period_end = models.BooleanField(default=False)
|
|
40
|
+
#: The period end a reminder was last sent for (one reminder per period).
|
|
41
|
+
reminded_for = models.DateTimeField(null=True, blank=True)
|
|
42
|
+
|
|
43
|
+
class Meta:
|
|
44
|
+
constraints = [
|
|
45
|
+
models.UniqueConstraint(fields=["user"], condition=Q(status__in=LIVE), name="univ_subs_one_live_per_user"),
|
|
46
|
+
]
|
|
47
|
+
indexes = [models.Index(fields=["status", "current_period_end"], name="univ_subs_status_end")]
|
|
48
|
+
|
|
49
|
+
def __str__(self) -> str:
|
|
50
|
+
return f"{self.plan} ({self.status})"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class Period(BaseModel):
|
|
54
|
+
"""One paid period: the receipt trail for a subscription."""
|
|
55
|
+
|
|
56
|
+
subscription = models.ForeignKey(Subscription, on_delete=models.CASCADE, related_name="periods")
|
|
57
|
+
starts_at = models.DateTimeField()
|
|
58
|
+
ends_at = models.DateTimeField()
|
|
59
|
+
amount_minor = models.PositiveBigIntegerField()
|
|
60
|
+
currency = models.CharField(max_length=3)
|
|
61
|
+
payment_reference = models.CharField(max_length=64, unique=True)
|
|
62
|
+
|
|
63
|
+
class Meta:
|
|
64
|
+
ordering = ("-starts_at",)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""How payments charges for a subscription period (Core's payable contract): the platform is the payee."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from university.core.exceptions import NotFound
|
|
8
|
+
from university.core.ids import is_valid_uuid
|
|
9
|
+
from university.core.payables import Payable, PayableAdapter
|
|
10
|
+
from university.subscriptions.conf import plans
|
|
11
|
+
from university.subscriptions.models import Subscription
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class PeriodPayable(PayableAdapter):
|
|
15
|
+
purpose = "subscriptions.period"
|
|
16
|
+
label = "Subscription"
|
|
17
|
+
|
|
18
|
+
def resolve(self, object_id: Any, payer: Any) -> Payable:
|
|
19
|
+
from university.subscriptions.services import amount_due
|
|
20
|
+
|
|
21
|
+
sub = Subscription.objects.filter(pk=object_id).first() if is_valid_uuid(object_id) else None
|
|
22
|
+
due = amount_due(sub) if sub is not None and sub.user_id == getattr(payer, "pk", None) else None
|
|
23
|
+
if sub is None or due is None:
|
|
24
|
+
raise NotFound("Nothing is due on this subscription.")
|
|
25
|
+
name = plans().get(sub.plan, {}).get("name", sub.plan)
|
|
26
|
+
return Payable(
|
|
27
|
+
purpose=self.purpose,
|
|
28
|
+
object_id=str(sub.pk),
|
|
29
|
+
payer_id=str(sub.user_id),
|
|
30
|
+
amount_minor=due,
|
|
31
|
+
currency=sub.currency,
|
|
32
|
+
description=f"{name} ({sub.get_interval_display().lower()})"[:120],
|
|
33
|
+
platform_fee_minor=due,
|
|
34
|
+
metadata={"subscription_id": str(sub.pk), "plan": sub.plan, "interval": sub.interval},
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
def on_paid(self, object_id: Any, *, reference: str, amount_minor: int, currency: str) -> None:
|
|
38
|
+
from university.subscriptions.services import pay_period
|
|
39
|
+
|
|
40
|
+
pay_period(object_id, reference=reference, amount_minor=amount_minor, currency=currency)
|
|
41
|
+
|
|
42
|
+
def on_refunded(self, object_id: Any, *, reference: str, amount_minor: int, currency: str) -> None:
|
|
43
|
+
from university.subscriptions.services import refund_period
|
|
44
|
+
|
|
45
|
+
refund_period(reference)
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""Subscriptions: members pay for a period at a time and hold their plan's entitlements meanwhile.
|
|
2
|
+
|
|
3
|
+
Rules:
|
|
4
|
+
|
|
5
|
+
* One live subscription per member (pending, active or in its grace period). Changing plan means
|
|
6
|
+
letting the current one end first: nobody pays twice for overlapping time.
|
|
7
|
+
* Periods are paid through university-payments (purpose ``subscriptions.period``). A payment
|
|
8
|
+
that arrives when nothing is due (a double payment) is refused, and payments refunds it.
|
|
9
|
+
* Renewals are paid by the member (no stored cards): a reminder goes out before the period ends,
|
|
10
|
+
and entitlements continue through a short grace period before the subscription expires.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import datetime as dt
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from django.db import IntegrityError, transaction
|
|
19
|
+
from django.utils import timezone
|
|
20
|
+
|
|
21
|
+
from university.audit.services import record as audit
|
|
22
|
+
from university.core import entitlements as core_entitlements
|
|
23
|
+
from university.core.events import publish
|
|
24
|
+
from university.core.exceptions import Conflict, NotFound, ValidationError
|
|
25
|
+
from university.core.ids import is_valid_uuid
|
|
26
|
+
from university.subscriptions import events
|
|
27
|
+
from university.subscriptions.conf import plans, price
|
|
28
|
+
from university.subscriptions.conf import subscriptions_settings as conf
|
|
29
|
+
from university.subscriptions.models import ENTITLED, LIVE, Period, Subscription, SubscriptionStatus
|
|
30
|
+
|
|
31
|
+
PURPOSE = "subscriptions.period"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _payload(sub: Subscription) -> dict[str, Any]:
|
|
35
|
+
return {"subscription_id": sub.pk, "user_id": sub.user_id, "plan": sub.plan}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def current(user: Any) -> Subscription | None:
|
|
39
|
+
"""The member's live subscription, if any."""
|
|
40
|
+
if user is None or not getattr(user, "is_authenticated", False):
|
|
41
|
+
return None
|
|
42
|
+
return Subscription.objects.filter(user=user, status__in=LIVE).first()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def user_entitlements(user: Any) -> list[str]:
|
|
46
|
+
"""Core hook: the entitlements of the member's plan while it's paid up (or in grace)."""
|
|
47
|
+
sub = Subscription.objects.filter(user=user, status__in=ENTITLED).only("plan").first()
|
|
48
|
+
if sub is None:
|
|
49
|
+
return []
|
|
50
|
+
return list(plans().get(sub.plan, {}).get("entitlements", []))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def subscribe(*, user: Any, plan: str, interval: str, currency: str) -> Subscription:
|
|
54
|
+
if plan not in plans():
|
|
55
|
+
raise ValidationError(details={"plan": ["Unknown plan."]})
|
|
56
|
+
if price(plan, interval, currency) is None:
|
|
57
|
+
raise ValidationError(details={"interval": ["This plan isn't sold for that interval and currency."]})
|
|
58
|
+
with transaction.atomic():
|
|
59
|
+
existing = Subscription.objects.select_for_update().filter(user=user, status__in=LIVE).first()
|
|
60
|
+
if existing is not None and existing.status != SubscriptionStatus.PENDING_PAYMENT:
|
|
61
|
+
raise Conflict(
|
|
62
|
+
"You already have a subscription. Let it end before choosing another plan.", code="already_subscribed"
|
|
63
|
+
)
|
|
64
|
+
if existing is not None:
|
|
65
|
+
# Not paid yet: just change what they're about to buy.
|
|
66
|
+
existing.plan, existing.interval, existing.currency = plan, interval, currency
|
|
67
|
+
existing.save(update_fields=["plan", "interval", "currency", "updated_at"])
|
|
68
|
+
return existing
|
|
69
|
+
try:
|
|
70
|
+
with transaction.atomic():
|
|
71
|
+
sub = Subscription.objects.create(user=user, plan=plan, interval=interval, currency=currency)
|
|
72
|
+
except IntegrityError:
|
|
73
|
+
raise Conflict("You already have a subscription.", code="already_subscribed") from None
|
|
74
|
+
audit("subscriptions.subscription.started", actor=user, target=sub, category="payments")
|
|
75
|
+
return sub
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def abandon(*, user: Any) -> None:
|
|
79
|
+
"""Drop an unpaid subscription (e.g. the member changed their mind at checkout)."""
|
|
80
|
+
updated = Subscription.objects.filter(user=user, status=SubscriptionStatus.PENDING_PAYMENT).update(
|
|
81
|
+
status=SubscriptionStatus.ABANDONED
|
|
82
|
+
)
|
|
83
|
+
if not updated:
|
|
84
|
+
raise NotFound("Nothing to cancel.")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def set_cancel_at_period_end(*, user: Any, cancel: bool) -> Subscription:
|
|
88
|
+
sub = Subscription.objects.filter(user=user, status__in=ENTITLED).first()
|
|
89
|
+
if sub is None:
|
|
90
|
+
raise NotFound("You don't have an active subscription.")
|
|
91
|
+
sub.cancel_at_period_end = cancel
|
|
92
|
+
sub.save(update_fields=["cancel_at_period_end", "updated_at"])
|
|
93
|
+
audit(
|
|
94
|
+
"subscriptions.subscription.cancelled" if cancel else "subscriptions.subscription.resumed",
|
|
95
|
+
actor=user,
|
|
96
|
+
target=sub,
|
|
97
|
+
category="payments",
|
|
98
|
+
)
|
|
99
|
+
return sub
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def amount_due(sub: Subscription, *, now: dt.datetime | None = None) -> int | None:
|
|
103
|
+
"""What paying now costs, or None when nothing is due yet (or the plan is no longer sold)."""
|
|
104
|
+
now = now or timezone.now()
|
|
105
|
+
if sub.status == SubscriptionStatus.ACTIVE:
|
|
106
|
+
window = dt.timedelta(days=int(conf.get("RENEWAL_WINDOW_DAYS", 7)))
|
|
107
|
+
if sub.current_period_end is None or sub.current_period_end - now > window:
|
|
108
|
+
return None
|
|
109
|
+
elif sub.status not in (SubscriptionStatus.PENDING_PAYMENT, SubscriptionStatus.PAST_DUE):
|
|
110
|
+
return None
|
|
111
|
+
return price(sub.plan, sub.interval, sub.currency)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def pay_period(object_id: Any, *, reference: str, amount_minor: int, currency: str) -> Subscription:
|
|
115
|
+
"""A period was paid: activate or extend. Called by the payable adapter."""
|
|
116
|
+
with transaction.atomic():
|
|
117
|
+
sub = (
|
|
118
|
+
Subscription.objects.select_for_update().filter(pk=object_id).first() if is_valid_uuid(object_id) else None
|
|
119
|
+
)
|
|
120
|
+
if sub is None:
|
|
121
|
+
raise NotFound("No such subscription.")
|
|
122
|
+
due = amount_due(sub)
|
|
123
|
+
if due is None or due != amount_minor or currency != sub.currency:
|
|
124
|
+
raise Conflict("Nothing is due on this subscription.", code="not_due")
|
|
125
|
+
now = timezone.now()
|
|
126
|
+
first = sub.status == SubscriptionStatus.PENDING_PAYMENT
|
|
127
|
+
# An early renewal continues from the end of the paid time; a new or lapsed one starts now.
|
|
128
|
+
restart = first or sub.status == SubscriptionStatus.PAST_DUE or sub.current_period_end is None
|
|
129
|
+
start = now if restart else max(now, sub.current_period_end)
|
|
130
|
+
end = start + dt.timedelta(days=int(conf.get("INTERVAL_DAYS", {}).get(sub.interval, 30)))
|
|
131
|
+
Period.objects.create(
|
|
132
|
+
subscription=sub,
|
|
133
|
+
starts_at=start,
|
|
134
|
+
ends_at=end,
|
|
135
|
+
amount_minor=amount_minor,
|
|
136
|
+
currency=currency,
|
|
137
|
+
payment_reference=reference,
|
|
138
|
+
)
|
|
139
|
+
if restart:
|
|
140
|
+
sub.current_period_start = start
|
|
141
|
+
sub.current_period_end = end
|
|
142
|
+
sub.status = SubscriptionStatus.ACTIVE
|
|
143
|
+
sub.cancel_at_period_end = False
|
|
144
|
+
sub.save()
|
|
145
|
+
core_entitlements.forget(sub.user)
|
|
146
|
+
publish(events.SUBSCRIPTION_ACTIVATED if first else events.SUBSCRIPTION_RENEWED, _payload(sub))
|
|
147
|
+
audit("subscriptions.period.paid", actor=None, target=sub, category="payments", metadata={"reference": reference})
|
|
148
|
+
return sub
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def refund_period(reference: str) -> None:
|
|
152
|
+
"""A paid period was refunded: take that time back. Payments that bought nothing are no-ops."""
|
|
153
|
+
with transaction.atomic():
|
|
154
|
+
period = Period.objects.select_related("subscription").filter(payment_reference=reference).first()
|
|
155
|
+
if period is None:
|
|
156
|
+
return
|
|
157
|
+
sub = Subscription.objects.select_for_update().get(pk=period.subscription_id)
|
|
158
|
+
length = period.ends_at - period.starts_at
|
|
159
|
+
if sub.current_period_end is not None:
|
|
160
|
+
sub.current_period_end = max(period.starts_at, sub.current_period_end - length)
|
|
161
|
+
sub.save(update_fields=["current_period_end", "updated_at"])
|
|
162
|
+
period.delete()
|
|
163
|
+
audit(
|
|
164
|
+
"subscriptions.period.refunded", actor=None, target=sub, category="payments", metadata={"reference": reference}
|
|
165
|
+
)
|
|
166
|
+
if sub.current_period_end is None or sub.current_period_end <= timezone.now():
|
|
167
|
+
_expire(sub)
|
|
168
|
+
core_entitlements.forget(sub.user)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def sweep(*, now: dt.datetime | None = None) -> dict[str, int]:
|
|
172
|
+
"""Move subscriptions along as time passes: reminders, grace and expiry. Safe to run often."""
|
|
173
|
+
now = now or timezone.now()
|
|
174
|
+
counts = {"reminded": 0, "past_due": 0, "expired": 0}
|
|
175
|
+
reminder_at = now + dt.timedelta(days=int(conf.get("REMINDER_DAYS", 3)))
|
|
176
|
+
for sub in Subscription.objects.filter(
|
|
177
|
+
status=SubscriptionStatus.ACTIVE,
|
|
178
|
+
cancel_at_period_end=False,
|
|
179
|
+
current_period_end__lte=reminder_at,
|
|
180
|
+
current_period_end__gt=now,
|
|
181
|
+
):
|
|
182
|
+
if sub.reminded_for != sub.current_period_end:
|
|
183
|
+
Subscription.objects.filter(pk=sub.pk).update(reminded_for=sub.current_period_end)
|
|
184
|
+
publish(events.SUBSCRIPTION_RENEWAL_DUE, _payload(sub))
|
|
185
|
+
counts["reminded"] += 1
|
|
186
|
+
ended = Subscription.objects.filter(status=SubscriptionStatus.ACTIVE, current_period_end__lte=now)
|
|
187
|
+
for sub in ended:
|
|
188
|
+
if sub.cancel_at_period_end:
|
|
189
|
+
_expire(sub)
|
|
190
|
+
counts["expired"] += 1
|
|
191
|
+
else:
|
|
192
|
+
Subscription.objects.filter(pk=sub.pk, status=SubscriptionStatus.ACTIVE).update(
|
|
193
|
+
status=SubscriptionStatus.PAST_DUE
|
|
194
|
+
)
|
|
195
|
+
counts["past_due"] += 1
|
|
196
|
+
grace = dt.timedelta(days=int(conf.get("GRACE_DAYS", 3)))
|
|
197
|
+
for sub in Subscription.objects.filter(status=SubscriptionStatus.PAST_DUE, current_period_end__lte=now - grace):
|
|
198
|
+
_expire(sub)
|
|
199
|
+
counts["expired"] += 1
|
|
200
|
+
stale = now - dt.timedelta(days=2)
|
|
201
|
+
Subscription.objects.filter(status=SubscriptionStatus.PENDING_PAYMENT, created_at__lte=stale).update(
|
|
202
|
+
status=SubscriptionStatus.ABANDONED
|
|
203
|
+
)
|
|
204
|
+
return counts
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _expire(sub: Subscription) -> None:
|
|
208
|
+
updated = Subscription.objects.filter(pk=sub.pk, status__in=ENTITLED).update(status=SubscriptionStatus.EXPIRED)
|
|
209
|
+
if updated:
|
|
210
|
+
publish(events.SUBSCRIPTION_EXPIRED, _payload(sub))
|
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from university.core.testing import * # noqa: F403
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from university.core import CORE_APPS
|
|
2
|
+
from university.core.testing import build_test_settings
|
|
3
|
+
|
|
4
|
+
globals().update(
|
|
5
|
+
build_test_settings(
|
|
6
|
+
installed_apps=["drf_spectacular", *CORE_APPS, "university.subscriptions"],
|
|
7
|
+
root_urlconf="tests.urls",
|
|
8
|
+
STORAGES={
|
|
9
|
+
"default": {"BACKEND": "django.core.files.storage.InMemoryStorage"},
|
|
10
|
+
"private": {"BACKEND": "django.core.files.storage.InMemoryStorage"},
|
|
11
|
+
"staticfiles": {"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"},
|
|
12
|
+
},
|
|
13
|
+
)
|
|
14
|
+
)
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Subscriptions: plans, paying periods, renewals, grace, expiry, refunds and entitlements."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime as dt
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
from django.utils import timezone
|
|
9
|
+
|
|
10
|
+
from university.core.entitlements import entitlements, forget, has_entitlement
|
|
11
|
+
from university.core.exceptions import Conflict, NotFound
|
|
12
|
+
from university.core.payables import get_payable_adapter
|
|
13
|
+
from university.core.testing import create_user
|
|
14
|
+
from university.subscriptions import services
|
|
15
|
+
from university.subscriptions.models import Subscription
|
|
16
|
+
|
|
17
|
+
pytestmark = pytest.mark.django_db
|
|
18
|
+
BASE = "/api/v1/subscriptions"
|
|
19
|
+
PRICE = 150000
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def subscribe(client, plan="plus", interval="month"):
|
|
23
|
+
response = client.post(f"{BASE}/me/", {"plan": plan, "interval": interval, "currency": "NGN"}, format="json")
|
|
24
|
+
assert response.status_code == 201, response.json()
|
|
25
|
+
return response.json()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def pay(sub_id, reference="ref-1", amount=PRICE):
|
|
29
|
+
get_payable_adapter("subscriptions.period").on_paid(
|
|
30
|
+
sub_id, reference=reference, amount_minor=amount, currency="NGN"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_plans_public(api_client):
|
|
35
|
+
body = api_client.get(f"{BASE}/plans/").json()
|
|
36
|
+
plus = next(p for p in body if p["key"] == "plus")
|
|
37
|
+
assert {"interval": "month", "currency": "NGN", "amount_minor": PRICE} in plus["prices"]
|
|
38
|
+
assert {"key": "premium.ad_free", "label": "No adverts"} in plus["entitlements"]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_subscribe_pay_and_hold_entitlements(auth_client, user):
|
|
42
|
+
client = auth_client(user)
|
|
43
|
+
sub = subscribe(client)
|
|
44
|
+
assert sub["status"] == "pending_payment"
|
|
45
|
+
assert sub["payment_due"] == {
|
|
46
|
+
"purpose": "subscriptions.period",
|
|
47
|
+
"object_id": sub["id"],
|
|
48
|
+
"amount_minor": PRICE,
|
|
49
|
+
"currency": "NGN",
|
|
50
|
+
}
|
|
51
|
+
assert not has_entitlement(user, "premium.ad_free")
|
|
52
|
+
payable = get_payable_adapter("subscriptions.period").resolve(sub["id"], user)
|
|
53
|
+
assert payable.amount_minor == PRICE and payable.payee_id is None and payable.platform_fee_minor == PRICE
|
|
54
|
+
pay(sub["id"])
|
|
55
|
+
forget(user)
|
|
56
|
+
assert has_entitlement(user, "premium.ad_free") and has_entitlement(user, "premium.badge")
|
|
57
|
+
me = client.get(f"{BASE}/me/").json()
|
|
58
|
+
assert me["subscription"]["status"] == "active" and me["subscription"]["payment_due"] is None
|
|
59
|
+
assert "premium.badge" in me["entitlements"] and len(me["subscription"]["periods"]) == 1
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_nobody_else_can_pay_and_double_payments_are_refused(auth_client, user):
|
|
63
|
+
sub = subscribe(auth_client(user))
|
|
64
|
+
with pytest.raises(NotFound):
|
|
65
|
+
get_payable_adapter("subscriptions.period").resolve(
|
|
66
|
+
sub["id"], create_user(handle="payer2", email="payer2@example.com")
|
|
67
|
+
)
|
|
68
|
+
pay(sub["id"])
|
|
69
|
+
# Nothing due now: a second checkout can't start, and a second payment can't be applied.
|
|
70
|
+
with pytest.raises(NotFound):
|
|
71
|
+
get_payable_adapter("subscriptions.period").resolve(sub["id"], user)
|
|
72
|
+
with pytest.raises(Conflict):
|
|
73
|
+
pay(sub["id"], reference="ref-2")
|
|
74
|
+
# The wrong amount is refused too (payments then refunds it).
|
|
75
|
+
Subscription.objects.filter(pk=sub["id"]).update(status="pending_payment")
|
|
76
|
+
with pytest.raises(Conflict):
|
|
77
|
+
pay(sub["id"], reference="ref-3", amount=1)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_one_live_subscription_and_plan_change_before_paying(auth_client, user):
|
|
81
|
+
client = auth_client(user)
|
|
82
|
+
first = subscribe(client)
|
|
83
|
+
changed = subscribe(client, plan="creator_pro", interval="year")
|
|
84
|
+
assert changed["id"] == first["id"] and changed["plan"] == "creator_pro"
|
|
85
|
+
pay(changed["id"], amount=5000000)
|
|
86
|
+
assert client.post(f"{BASE}/me/", {"plan": "plus", "interval": "month"}, format="json").status_code == 409
|
|
87
|
+
assert client.post(f"{BASE}/me/", {"plan": "gold", "interval": "month"}, format="json").status_code == 400
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_renewal_window_extends_from_period_end(auth_client, user):
|
|
91
|
+
sub = subscribe(auth_client(user))
|
|
92
|
+
pay(sub["id"])
|
|
93
|
+
obj = Subscription.objects.get(pk=sub["id"])
|
|
94
|
+
end = obj.current_period_end
|
|
95
|
+
assert services.amount_due(obj) is None
|
|
96
|
+
Subscription.objects.filter(pk=obj.pk).update(current_period_end=timezone.now() + dt.timedelta(days=2))
|
|
97
|
+
obj.refresh_from_db()
|
|
98
|
+
assert services.amount_due(obj) == PRICE
|
|
99
|
+
pay(sub["id"], reference="ref-renew")
|
|
100
|
+
obj.refresh_from_db()
|
|
101
|
+
assert obj.current_period_end > end
|
|
102
|
+
assert abs((obj.current_period_end - timezone.now()) - dt.timedelta(days=32)) < dt.timedelta(minutes=1)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_sweep_reminds_then_grace_then_expires(auth_client, user):
|
|
106
|
+
from university.core.events import event_bus
|
|
107
|
+
|
|
108
|
+
sub = subscribe(auth_client(user))
|
|
109
|
+
pay(sub["id"])
|
|
110
|
+
seen = []
|
|
111
|
+
event_bus.subscribe("subscriptions.*", seen.append)
|
|
112
|
+
try:
|
|
113
|
+
Subscription.objects.filter(pk=sub["id"]).update(current_period_end=timezone.now() + dt.timedelta(days=1))
|
|
114
|
+
assert services.sweep()["reminded"] == 1
|
|
115
|
+
assert services.sweep()["reminded"] == 0 # once per period
|
|
116
|
+
Subscription.objects.filter(pk=sub["id"]).update(current_period_end=timezone.now() - dt.timedelta(hours=1))
|
|
117
|
+
assert services.sweep()["past_due"] == 1
|
|
118
|
+
forget(user)
|
|
119
|
+
assert has_entitlement(user, "premium.ad_free") # grace period
|
|
120
|
+
Subscription.objects.filter(pk=sub["id"]).update(current_period_end=timezone.now() - dt.timedelta(days=4))
|
|
121
|
+
assert services.sweep()["expired"] == 1
|
|
122
|
+
finally:
|
|
123
|
+
event_bus.unsubscribe("subscriptions.*", seen.append)
|
|
124
|
+
forget(user)
|
|
125
|
+
assert entitlements(user) == frozenset()
|
|
126
|
+
assert [e.name for e in seen] == ["subscriptions.subscription.renewal_due", "subscriptions.subscription.expired"]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_lapsed_subscription_can_be_paid_in_grace(auth_client, user):
|
|
130
|
+
sub = subscribe(auth_client(user))
|
|
131
|
+
pay(sub["id"])
|
|
132
|
+
Subscription.objects.filter(pk=sub["id"]).update(
|
|
133
|
+
status="past_due", current_period_end=timezone.now() - dt.timedelta(days=1)
|
|
134
|
+
)
|
|
135
|
+
pay(sub["id"], reference="ref-late")
|
|
136
|
+
obj = Subscription.objects.get(pk=sub["id"])
|
|
137
|
+
assert obj.status == "active" and obj.current_period_end > timezone.now() + dt.timedelta(days=29)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def test_cancel_at_period_end(auth_client, user):
|
|
141
|
+
client = auth_client(user)
|
|
142
|
+
sub = subscribe(client)
|
|
143
|
+
assert client.post(f"{BASE}/me/cancel/", {}, format="json").status_code == 404
|
|
144
|
+
pay(sub["id"])
|
|
145
|
+
assert client.post(f"{BASE}/me/cancel/", {}, format="json").json()["cancel_at_period_end"] is True
|
|
146
|
+
Subscription.objects.filter(pk=sub["id"]).update(current_period_end=timezone.now() + dt.timedelta(days=1))
|
|
147
|
+
assert services.sweep()["reminded"] == 0
|
|
148
|
+
Subscription.objects.filter(pk=sub["id"]).update(current_period_end=timezone.now() - dt.timedelta(minutes=1))
|
|
149
|
+
assert services.sweep()["expired"] == 1
|
|
150
|
+
assert client.get(f"{BASE}/me/").json()["subscription"] is None
|
|
151
|
+
# A new plan can be chosen once the old one ended.
|
|
152
|
+
assert subscribe(client)["status"] == "pending_payment"
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def test_abandon_unpaid(auth_client, user):
|
|
156
|
+
client = auth_client(user)
|
|
157
|
+
subscribe(client)
|
|
158
|
+
assert client.delete(f"{BASE}/me/").status_code == 204
|
|
159
|
+
assert client.delete(f"{BASE}/me/").status_code == 404
|
|
160
|
+
assert client.get(f"{BASE}/me/").json()["subscription"] is None
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def test_refund_takes_time_back(auth_client, user):
|
|
164
|
+
sub = subscribe(auth_client(user))
|
|
165
|
+
pay(sub["id"])
|
|
166
|
+
adapter = get_payable_adapter("subscriptions.period")
|
|
167
|
+
adapter.on_refunded(sub["id"], reference="unrelated", amount_minor=PRICE, currency="NGN") # no-op
|
|
168
|
+
assert Subscription.objects.get(pk=sub["id"]).status == "active"
|
|
169
|
+
adapter.on_refunded(sub["id"], reference="ref-1", amount_minor=PRICE, currency="NGN")
|
|
170
|
+
assert Subscription.objects.get(pk=sub["id"]).status == "expired"
|
|
171
|
+
forget(user)
|
|
172
|
+
assert not has_entitlement(user, "premium.ad_free")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def test_public_badge_export_and_purge(auth_client, api_client, user):
|
|
176
|
+
from university.subscriptions.integrations import export_data, purge
|
|
177
|
+
|
|
178
|
+
sub = subscribe(auth_client(user))
|
|
179
|
+
assert api_client.get(f"/api/v1/users/by-handle/{user.handle}/").json()["extras"]["premium"] is False
|
|
180
|
+
pay(sub["id"])
|
|
181
|
+
assert api_client.get(f"/api/v1/users/by-handle/{user.handle}/").json()["extras"]["premium"] is True
|
|
182
|
+
exported = export_data(user)["subscriptions"][0]
|
|
183
|
+
assert exported["plan"] == "plus" and exported["periods"][0]["amount_minor"] == PRICE
|
|
184
|
+
purge(user.pk)
|
|
185
|
+
assert not Subscription.objects.exists()
|