Skip to content

Commit bba2820

Browse files
hc-sousacursoragent
andcommitted
feat(api): account auth + unified premium entitlement
Add a REST account/auth surface and a source-aware premium entitlement that honors legacy email subscriptions, replacing the email allow-list as the premium source of truth. Auth (user_management): - DRF TokenAuthentication enabled globally (AllowAny default preserved); load user_management's REST surface without the allauth login wall - POST /api/v3/auth/{register,login,me,logout} (email+password, no email verify) - POST /api/v3/auth/social: server-side Apple/Google id-token verification via provider JWKS (PyJWT), find-or-link user by verified email Billing: - billing.Entitlement (tier/source/status/platform/current_period_end/features), admin-revocable via status; source tracks legacy_email | manual | revenuecat | stripe - ensure_legacy_entitlement honors active legacy subs on every auth event - resolve_entitlement (paid > manual > legacy) + manage_via routing - GET /api/v3/billing/entitlement (auth required, fail-safe free) - POST /api/v3/billing/webhooks/revenuecat reconcile seam (no client SDK yet) - lowercase email in legacy verify + compat shim (case-sensitivity fix) 21 new tests pass (user_management + billing). Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 67b6b54 commit bba2820

18 files changed

Lines changed: 1041 additions & 10 deletions

src/billing/admin.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from django.contrib import admin
22

3-
from billing.models import Subscription
3+
from billing.models import Entitlement, Subscription
44

55

66
@admin.register(Subscription)
@@ -10,3 +10,15 @@ class SubscriptionAdmin(admin.ModelAdmin):
1010
search_fields = ('email',)
1111
date_hierarchy = 'created_at'
1212
ordering = ('-created_at',)
13+
14+
15+
@admin.register(Entitlement)
16+
class EntitlementAdmin(admin.ModelAdmin):
17+
# status is editable inline so staff can revoke legacy/manual premium quickly.
18+
list_display = ('id', 'user', 'email', 'tier', 'source', 'status', 'current_period_end', 'updated_at')
19+
list_editable = ('status',)
20+
list_filter = ('tier', 'source', 'status', 'platform')
21+
search_fields = ('email', 'user__email', 'user__username', 'external_id')
22+
raw_id_fields = ('user',)
23+
date_hierarchy = 'created_at'
24+
ordering = ('-updated_at',)

src/billing/api_v3.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
"""Billing v3 API: entitlement read + RevenueCat reconcile webhook (seam)."""
2+
3+
from __future__ import annotations
4+
5+
from django.conf import settings
6+
from django.views.decorators.csrf import csrf_exempt
7+
from rest_framework import status
8+
from rest_framework.decorators import api_view, permission_classes
9+
from rest_framework.permissions import AllowAny, IsAuthenticated
10+
from rest_framework.request import Request
11+
from rest_framework.response import Response
12+
13+
from billing.services import entitlement_response, reconcile_revenuecat
14+
15+
16+
@api_view(['GET'])
17+
@permission_classes([IsAuthenticated])
18+
def entitlement_view(request: Request) -> Response:
19+
return Response(entitlement_response(request.user))
20+
21+
22+
@api_view(['POST'])
23+
@permission_classes([AllowAny])
24+
@csrf_exempt
25+
def revenuecat_webhook(request: Request) -> Response:
26+
"""Future-IAP seam: reconcile RevenueCat events into Entitlement.
27+
28+
Authorized by a shared secret sent in the Authorization header (configured
29+
in the RevenueCat dashboard). No client SDK is wired yet.
30+
"""
31+
secret = getattr(settings, 'REVENUECAT_WEBHOOK_SECRET', '')
32+
provided = request.headers.get('Authorization', '')
33+
if not secret or provided != secret:
34+
return Response({'error': 'unauthorized'}, status=status.HTTP_400_BAD_REQUEST)
35+
36+
body = request.data if isinstance(request.data, dict) else {}
37+
event = body.get('event') or {}
38+
reconcile_revenuecat(event)
39+
return Response({'status': 'ok'})
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
# Generated by Django 5.0.6 on 2026-06-04 17:15
2+
3+
import billing.models
4+
import django.db.models.deletion
5+
from django.conf import settings
6+
from django.db import migrations, models
7+
8+
9+
class Migration(migrations.Migration):
10+
11+
dependencies = [
12+
("billing", "0001_initial"),
13+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
14+
]
15+
16+
operations = [
17+
migrations.CreateModel(
18+
name="Entitlement",
19+
fields=[
20+
(
21+
"id",
22+
models.BigAutoField(
23+
auto_created=True,
24+
primary_key=True,
25+
serialize=False,
26+
verbose_name="ID",
27+
),
28+
),
29+
("email", models.EmailField(blank=True, db_index=True, max_length=254)),
30+
(
31+
"tier",
32+
models.CharField(
33+
choices=[("free", "Free"), ("premium", "Premium")],
34+
default="premium",
35+
max_length=16,
36+
),
37+
),
38+
(
39+
"source",
40+
models.CharField(
41+
choices=[
42+
("legacy_email", "Legacy email allow-list"),
43+
("manual", "Manual grant"),
44+
("revenuecat", "RevenueCat (IAP)"),
45+
("stripe", "Stripe"),
46+
],
47+
max_length=20,
48+
),
49+
),
50+
(
51+
"status",
52+
models.CharField(
53+
choices=[
54+
("active", "Active"),
55+
("canceled", "Canceled"),
56+
("expired", "Expired"),
57+
],
58+
default="active",
59+
max_length=16,
60+
),
61+
),
62+
(
63+
"platform",
64+
models.CharField(
65+
blank=True,
66+
choices=[
67+
("ios", "iOS"),
68+
("android", "Android"),
69+
("web", "Web"),
70+
],
71+
max_length=10,
72+
),
73+
),
74+
("external_id", models.CharField(blank=True, max_length=255)),
75+
("current_period_end", models.DateTimeField(blank=True, null=True)),
76+
("features", models.JSONField(default=billing.models.default_features)),
77+
("created_at", models.DateTimeField(auto_now_add=True)),
78+
("updated_at", models.DateTimeField(auto_now=True)),
79+
(
80+
"user",
81+
models.ForeignKey(
82+
blank=True,
83+
null=True,
84+
on_delete=django.db.models.deletion.CASCADE,
85+
related_name="entitlements",
86+
to=settings.AUTH_USER_MODEL,
87+
),
88+
),
89+
],
90+
options={
91+
"db_table": "billing_entitlement",
92+
"indexes": [
93+
models.Index(
94+
fields=["user", "status"], name="billing_ent_user_id_a06777_idx"
95+
),
96+
models.Index(
97+
fields=["email", "status"], name="billing_ent_email_29a145_idx"
98+
),
99+
models.Index(
100+
fields=["source", "status"],
101+
name="billing_ent_source_1376df_idx",
102+
),
103+
],
104+
},
105+
),
106+
migrations.AddConstraint(
107+
model_name="entitlement",
108+
constraint=models.UniqueConstraint(
109+
condition=models.Q(("user__isnull", False)),
110+
fields=("user", "source"),
111+
name="uniq_entitlement_user_source",
112+
),
113+
),
114+
]

src/billing/models.py

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,19 @@
1-
"""Legacy email subscription allow-list (compat with old subscriptions app)."""
1+
"""Billing models.
22
3+
``Subscription`` is the legacy email allow-list (compat with the old
4+
subscriptions app). ``Entitlement`` is the unified, source-aware source of
5+
truth for premium across legacy, manual grants, and (future) RevenueCat/Stripe.
6+
"""
7+
8+
from django.conf import settings
39
from django.core.validators import EmailValidator
410
from django.db import models
511

612

13+
def default_features() -> list:
14+
return ['ad_removal']
15+
16+
717
class Subscription(models.Model):
818
id = models.AutoField(primary_key=True)
919
email = models.EmailField(validators=[EmailValidator()], unique=True)
@@ -22,3 +32,74 @@ class Meta:
2232
def __str__(self) -> str:
2333
status = 'Active' if self.is_active else 'Inactive'
2434
return f'{self.email} - {status}'
35+
36+
37+
class Entitlement(models.Model):
38+
"""Unified premium entitlement — one row per (user/email, source)."""
39+
40+
TIER_FREE = 'free'
41+
TIER_PREMIUM = 'premium'
42+
TIER_CHOICES = [(TIER_FREE, 'Free'), (TIER_PREMIUM, 'Premium')]
43+
44+
SOURCE_LEGACY = 'legacy_email'
45+
SOURCE_MANUAL = 'manual'
46+
SOURCE_REVENUECAT = 'revenuecat'
47+
SOURCE_STRIPE = 'stripe'
48+
SOURCE_CHOICES = [
49+
(SOURCE_LEGACY, 'Legacy email allow-list'),
50+
(SOURCE_MANUAL, 'Manual grant'),
51+
(SOURCE_REVENUECAT, 'RevenueCat (IAP)'),
52+
(SOURCE_STRIPE, 'Stripe'),
53+
]
54+
55+
STATUS_ACTIVE = 'active'
56+
STATUS_CANCELED = 'canceled'
57+
STATUS_EXPIRED = 'expired'
58+
STATUS_CHOICES = [
59+
(STATUS_ACTIVE, 'Active'),
60+
(STATUS_CANCELED, 'Canceled'),
61+
(STATUS_EXPIRED, 'Expired'),
62+
]
63+
64+
PLATFORM_CHOICES = [('ios', 'iOS'), ('android', 'Android'), ('web', 'Web')]
65+
66+
user = models.ForeignKey(
67+
settings.AUTH_USER_MODEL,
68+
on_delete=models.CASCADE,
69+
related_name='entitlements',
70+
null=True,
71+
blank=True,
72+
)
73+
email = models.EmailField(blank=True, db_index=True)
74+
tier = models.CharField(max_length=16, choices=TIER_CHOICES, default=TIER_PREMIUM)
75+
source = models.CharField(max_length=20, choices=SOURCE_CHOICES)
76+
status = models.CharField(max_length=16, choices=STATUS_CHOICES, default=STATUS_ACTIVE)
77+
platform = models.CharField(max_length=10, choices=PLATFORM_CHOICES, blank=True)
78+
external_id = models.CharField(max_length=255, blank=True)
79+
current_period_end = models.DateTimeField(null=True, blank=True)
80+
features = models.JSONField(default=default_features)
81+
created_at = models.DateTimeField(auto_now_add=True)
82+
updated_at = models.DateTimeField(auto_now=True)
83+
84+
class Meta:
85+
db_table = 'billing_entitlement'
86+
constraints = [
87+
models.UniqueConstraint(
88+
fields=['user', 'source'],
89+
name='uniq_entitlement_user_source',
90+
condition=models.Q(user__isnull=False),
91+
),
92+
]
93+
indexes = [
94+
models.Index(fields=['user', 'status']),
95+
models.Index(fields=['email', 'status']),
96+
models.Index(fields=['source', 'status']),
97+
]
98+
99+
def __str__(self) -> str:
100+
who = self.user_id or self.email or 'unlinked'
101+
return f'{who} - {self.tier}/{self.source} ({self.status})'
102+
103+
@property
104+
def is_active(self) -> bool:
105+
return self.status == self.STATUS_ACTIVE and self.tier == self.TIER_PREMIUM

0 commit comments

Comments
 (0)