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.
Files changed (26) hide show
  1. university_subscriptions-0.1.0/.gitignore +42 -0
  2. university_subscriptions-0.1.0/LICENSE +21 -0
  3. university_subscriptions-0.1.0/PKG-INFO +94 -0
  4. university_subscriptions-0.1.0/README.md +71 -0
  5. university_subscriptions-0.1.0/pyproject.toml +41 -0
  6. university_subscriptions-0.1.0/src/university/subscriptions/__init__.py +3 -0
  7. university_subscriptions-0.1.0/src/university/subscriptions/admin.py +19 -0
  8. university_subscriptions-0.1.0/src/university/subscriptions/api/__init__.py +0 -0
  9. university_subscriptions-0.1.0/src/university/subscriptions/api/v1/__init__.py +0 -0
  10. university_subscriptions-0.1.0/src/university/subscriptions/api/v1/serializers.py +104 -0
  11. university_subscriptions-0.1.0/src/university/subscriptions/api/v1/urls.py +13 -0
  12. university_subscriptions-0.1.0/src/university/subscriptions/api/v1/views.py +66 -0
  13. university_subscriptions-0.1.0/src/university/subscriptions/apps.py +34 -0
  14. university_subscriptions-0.1.0/src/university/subscriptions/conf.py +58 -0
  15. university_subscriptions-0.1.0/src/university/subscriptions/events.py +10 -0
  16. university_subscriptions-0.1.0/src/university/subscriptions/integrations.py +49 -0
  17. university_subscriptions-0.1.0/src/university/subscriptions/migrations/0001_initial.py +61 -0
  18. university_subscriptions-0.1.0/src/university/subscriptions/migrations/__init__.py +0 -0
  19. university_subscriptions-0.1.0/src/university/subscriptions/models.py +64 -0
  20. university_subscriptions-0.1.0/src/university/subscriptions/payables.py +45 -0
  21. university_subscriptions-0.1.0/src/university/subscriptions/services.py +210 -0
  22. university_subscriptions-0.1.0/tests/__init__.py +0 -0
  23. university_subscriptions-0.1.0/tests/conftest.py +1 -0
  24. university_subscriptions-0.1.0/tests/settings.py +14 -0
  25. university_subscriptions-0.1.0/tests/test_subscriptions.py +185 -0
  26. 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,3 @@
1
+ """University Subscriptions: premium plans, paid periods and entitlements."""
2
+
3
+ __version__ = "0.1.0"
@@ -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]
@@ -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
+ ]
@@ -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()
@@ -0,0 +1,3 @@
1
+ from django.urls import include, path
2
+
3
+ urlpatterns = [path("", include("university.core.urls"))]