## Summary - Closes [#34](#34) - Companion for [chat_web_app#75](ai_ml_operations/chat_web_app#75) (portal cancel/change local sync) - Soft-delete `DELETE /api/user/` for the authenticated user only: `deleted=True`, `is_active=False`, hide conversations, blacklist outstanding refresh tokens; staff self-delete rejected - Stripe `customer.subscription.updated` / `deleted` webhooks sync plan status, `cancel_at_period_end`, and `current_period_end`; checkout assigns plan from `metadata.plan_slug` - **UserAuthEvent audit**: `account_deleted`, `subscription_started` (first active plan), `subscription_updated` (plan/status/cancel changes) — visible on user admin - Document FE contract in README (endpoint, response, post-delete logout) ## Test plan - [ ] `uv run python manage.py test chat_backend.tests.test_views_users.CustomUserSelfDeleteTestCase finance.tests` - [ ] Authenticated `DELETE /api/user/` soft-deletes self, hides conversations, blocks re-login, writes `account_deleted` auth event - [ ] Checkout / Backer assign writes `subscription_started`; portal cancel/change writes `subscription_updated` - [ ] Anonymous / staff self-delete rejected; body cannot target another user - [ ] After portal cancel, webhook sets `cancel_at_period_end` / `canceled` on `GET /finance/subscription/`Reviewed-on: #39
This commit was merged in pull request #39.
This commit is contained in:
@@ -180,6 +180,35 @@ and frontend [chat_web_app#35](https://git.aimloperations.com/ai_ml_operations/c
|
|||||||
Push/merge to `master` auto-deploys **beta** only. Prod requires the Gitea
|
Push/merge to `master` auto-deploys **beta** only. Prod requires the Gitea
|
||||||
**Run workflow** button on **Deploy Prod**. Deploy never runs on PRs.
|
**Run workflow** button on **Deploy Prod**. Deploy never runs on PRs.
|
||||||
|
|
||||||
|
## Frontend API notes
|
||||||
|
|
||||||
|
### Self-delete account ([#34](https://git.aimloperations.com/ai_ml_operations/chat_backend/issues/34))
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|--|--|
|
||||||
|
| Method / path | `DELETE /api/user/` |
|
||||||
|
| Auth | JWT (authenticated user only; always deletes `request.user`) |
|
||||||
|
| Optional body | `{ "refresh_token": "<current refresh>" }` |
|
||||||
|
| Success | `200` `{ "detail": "Account deleted.", "deleted": true }` |
|
||||||
|
| Effects | Sets `deleted=True`, `is_active=False`; soft-deletes conversations; blacklists outstanding refresh tokens; logs `UserAuthEvent` `account_deleted` |
|
||||||
|
| Staff | Staff/superuser self-delete rejected (`400`, `code=staff_forbidden`) |
|
||||||
|
| Privacy v1 | Soft-delete only (no anonymization / hard purge) |
|
||||||
|
|
||||||
|
Post-delete UX: clear local tokens → redirect to sign-in. Subsequent
|
||||||
|
`/token/obtain/` fails. Do **not** send another user's id/email — ignored.
|
||||||
|
|
||||||
|
### Subscription change / cancel (portal + webhooks)
|
||||||
|
|
||||||
|
Plan change and cancel stay on Stripe Customer Portal
|
||||||
|
(`POST /api/finance/portal/`). Local state syncs via
|
||||||
|
`customer.subscription.updated` / `deleted` webhooks.
|
||||||
|
`GET /api/finance/subscription/` includes `cancel_at_period_end` and
|
||||||
|
`current_period_end` for Account UI messaging.
|
||||||
|
|
||||||
|
Subscription audit (`UserAuthEvent` on the user admin):
|
||||||
|
- `subscription_started` — first active plan (Checkout, Backer redeem, admin assign)
|
||||||
|
- `subscription_updated` — plan/status/cancel-at-period-end changes (portal + webhooks)
|
||||||
|
|
||||||
## Security note
|
## Security note
|
||||||
|
|
||||||
Secrets previously hardcoded in `settings.py` (email password, captcha, Django
|
Secrets previously hardcoded in `settings.py` (email password, captcha, Django
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Generated by Django 6.0 on 2026-08-01 19:21
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("chat_backend", "0026_oauthidentity"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="userauthevent",
|
||||||
|
name="event_type",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("password_reset_requested", "Password reset requested"),
|
||||||
|
("password_set", "Password set"),
|
||||||
|
("invite_sent", "Invite sent"),
|
||||||
|
("account_deleted", "Account deleted"),
|
||||||
|
("subscription_started", "Subscription started"),
|
||||||
|
("subscription_updated", "Subscription updated"),
|
||||||
|
],
|
||||||
|
max_length=64,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -82,7 +82,7 @@ class CustomUser(AbstractUser):
|
|||||||
|
|
||||||
|
|
||||||
class UserAuthEvent(models.Model):
|
class UserAuthEvent(models.Model):
|
||||||
"""Audit trail for password reset / set actions, shown on user admin."""
|
"""Audit trail for auth / account / subscription actions (user admin)."""
|
||||||
|
|
||||||
class EventType(models.TextChoices):
|
class EventType(models.TextChoices):
|
||||||
PASSWORD_RESET_REQUESTED = (
|
PASSWORD_RESET_REQUESTED = (
|
||||||
@@ -91,6 +91,9 @@ class UserAuthEvent(models.Model):
|
|||||||
)
|
)
|
||||||
PASSWORD_SET = ("password_set", "Password set")
|
PASSWORD_SET = ("password_set", "Password set")
|
||||||
INVITE_SENT = ("invite_sent", "Invite sent")
|
INVITE_SENT = ("invite_sent", "Invite sent")
|
||||||
|
ACCOUNT_DELETED = ("account_deleted", "Account deleted")
|
||||||
|
SUBSCRIPTION_STARTED = ("subscription_started", "Subscription started")
|
||||||
|
SUBSCRIPTION_UPDATED = ("subscription_updated", "Subscription updated")
|
||||||
|
|
||||||
user = models.ForeignKey(
|
user = models.ForeignKey(
|
||||||
CustomUser,
|
CustomUser,
|
||||||
|
|||||||
@@ -26,6 +26,17 @@ class MyTokenObtainPairSerializer(TokenObtainPairSerializer):
|
|||||||
|
|
||||||
return token
|
return token
|
||||||
|
|
||||||
|
def validate(self, attrs):
|
||||||
|
data = super().validate(attrs)
|
||||||
|
if getattr(self.user, "deleted", False):
|
||||||
|
from rest_framework_simplejwt.exceptions import AuthenticationFailed
|
||||||
|
|
||||||
|
raise AuthenticationFailed(
|
||||||
|
"No active account found with the given credentials",
|
||||||
|
code="no_active_account",
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
class CompanySerializer(serializers.ModelSerializer):
|
class CompanySerializer(serializers.ModelSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
@@ -70,12 +81,18 @@ class CustomUserSerializer(serializers.ModelSerializer):
|
|||||||
"status": UserSubscription.Status.NONE,
|
"status": UserSubscription.Status.NONE,
|
||||||
"source": UserSubscription.Source.NONE,
|
"source": UserSubscription.Source.NONE,
|
||||||
"needs_checkout": True,
|
"needs_checkout": True,
|
||||||
|
"cancel_at_period_end": False,
|
||||||
|
"current_period_end": None,
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
"plan": plan_to_dict(sub.plan) if sub.plan_id else None,
|
"plan": plan_to_dict(sub.plan) if sub.plan_id else None,
|
||||||
"status": sub.status,
|
"status": sub.status,
|
||||||
"source": sub.source,
|
"source": sub.source,
|
||||||
"needs_checkout": needs_checkout(obj),
|
"needs_checkout": needs_checkout(obj),
|
||||||
|
"cancel_at_period_end": bool(sub.cancel_at_period_end),
|
||||||
|
"current_period_end": (
|
||||||
|
sub.current_period_end.isoformat() if sub.current_period_end else None
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
"""Self-service account soft-delete helpers (#34)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from django.db import transaction
|
||||||
|
from rest_framework_simplejwt.token_blacklist.models import (
|
||||||
|
BlacklistedToken,
|
||||||
|
OutstandingToken,
|
||||||
|
)
|
||||||
|
from rest_framework_simplejwt.tokens import RefreshToken
|
||||||
|
|
||||||
|
from chat_backend.models import Conversation, CustomUser, UserAuthEvent
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class AccountDeletionError(Exception):
|
||||||
|
"""Raised when self-delete is not allowed for the requesting user."""
|
||||||
|
|
||||||
|
def __init__(self, detail: str, *, code: str = "delete_forbidden"):
|
||||||
|
super().__init__(detail)
|
||||||
|
self.detail = detail
|
||||||
|
self.code = code
|
||||||
|
|
||||||
|
|
||||||
|
def _blacklist_outstanding_tokens(user: CustomUser) -> int:
|
||||||
|
"""Blacklist all outstanding refresh tokens for the user. Returns count."""
|
||||||
|
count = 0
|
||||||
|
for outstanding in OutstandingToken.objects.filter(user=user):
|
||||||
|
_token, created = BlacklistedToken.objects.get_or_create(token=outstanding)
|
||||||
|
if created:
|
||||||
|
count += 1
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
def _blacklist_refresh_token(refresh_token: str | None) -> None:
|
||||||
|
if not refresh_token:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
RefreshToken(refresh_token).blacklist()
|
||||||
|
except Exception:
|
||||||
|
logger.info("Self-delete: optional refresh token could not be blacklisted")
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def soft_delete_account(
|
||||||
|
user: CustomUser,
|
||||||
|
*,
|
||||||
|
refresh_token: str | None = None,
|
||||||
|
ip_address: str | None = None,
|
||||||
|
) -> CustomUser:
|
||||||
|
"""
|
||||||
|
Soft-delete the requesting user and hide their conversations.
|
||||||
|
|
||||||
|
Privacy (v1): personal data is retained under soft-delete for admin/audit.
|
||||||
|
Full purge (chats, documents, RAG vectors, auth events) is a follow-up.
|
||||||
|
"""
|
||||||
|
if user.is_staff or user.is_superuser:
|
||||||
|
raise AccountDeletionError(
|
||||||
|
"Staff accounts cannot self-delete. Contact an administrator.",
|
||||||
|
code="staff_forbidden",
|
||||||
|
)
|
||||||
|
|
||||||
|
if user.deleted:
|
||||||
|
raise AccountDeletionError(
|
||||||
|
"This account has already been deleted.",
|
||||||
|
code="already_deleted",
|
||||||
|
)
|
||||||
|
|
||||||
|
user.deleted = True
|
||||||
|
user.is_active = False
|
||||||
|
user.save(update_fields=["deleted", "is_active"])
|
||||||
|
|
||||||
|
Conversation.objects.filter(user=user, deleted=False).update(deleted=True)
|
||||||
|
|
||||||
|
UserAuthEvent.log(
|
||||||
|
user,
|
||||||
|
UserAuthEvent.EventType.ACCOUNT_DELETED,
|
||||||
|
detail="Self-service account soft-delete",
|
||||||
|
ip_address=ip_address,
|
||||||
|
)
|
||||||
|
|
||||||
|
_blacklist_refresh_token(refresh_token)
|
||||||
|
blacklisted = _blacklist_outstanding_tokens(user)
|
||||||
|
logger.info(
|
||||||
|
"Soft-deleted user pk=%s; blacklisted_outstanding=%s",
|
||||||
|
user.pk,
|
||||||
|
blacklisted,
|
||||||
|
)
|
||||||
|
return user
|
||||||
@@ -7,13 +7,18 @@ from rest_framework_simplejwt.tokens import RefreshToken
|
|||||||
|
|
||||||
from chat_backend.models import (
|
from chat_backend.models import (
|
||||||
Announcement,
|
Announcement,
|
||||||
|
Conversation,
|
||||||
CustomUser,
|
CustomUser,
|
||||||
Feedback,
|
Feedback,
|
||||||
OutboundEmail,
|
OutboundEmail,
|
||||||
UserAuthEvent,
|
UserAuthEvent,
|
||||||
)
|
)
|
||||||
|
from rest_framework_simplejwt.token_blacklist.models import (
|
||||||
|
BlacklistedToken,
|
||||||
|
OutstandingToken,
|
||||||
|
)
|
||||||
|
|
||||||
from .factories import make_company, make_user
|
from .factories import make_company, make_conversation, make_user
|
||||||
|
|
||||||
|
|
||||||
class AuthenticationRequiredTestCase(APITestCase):
|
class AuthenticationRequiredTestCase(APITestCase):
|
||||||
@@ -520,3 +525,102 @@ class CustomUserGetTestCase(APITestCase):
|
|||||||
self.assertEqual(response.data["email"], user.email)
|
self.assertEqual(response.data["email"], user.email)
|
||||||
self.assertEqual(response.data["company"]["name"], "Globex")
|
self.assertEqual(response.data["company"]["name"], "Globex")
|
||||||
self.assertNotIn("password", response.data)
|
self.assertNotIn("password", response.data)
|
||||||
|
|
||||||
|
|
||||||
|
class CustomUserSelfDeleteTestCase(APITestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.company = make_company("Acme")
|
||||||
|
self.user = make_user(
|
||||||
|
email="deleteme@example.com",
|
||||||
|
password="testpass123",
|
||||||
|
company=self.company,
|
||||||
|
)
|
||||||
|
self.url = reverse("delete_user")
|
||||||
|
|
||||||
|
def test_unauthenticated_rejected(self):
|
||||||
|
response = self.client.delete(self.url)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|
||||||
|
def test_soft_deletes_self_and_hides_conversations(self):
|
||||||
|
conversation = make_conversation(user=self.user, title="Keep hidden")
|
||||||
|
refresh = RefreshToken.for_user(self.user)
|
||||||
|
self.client.force_authenticate(user=self.user)
|
||||||
|
|
||||||
|
response = self.client.delete(
|
||||||
|
self.url, {"refresh_token": str(refresh)}, format="json"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertTrue(response.data["deleted"])
|
||||||
|
|
||||||
|
self.user.refresh_from_db()
|
||||||
|
self.assertTrue(self.user.deleted)
|
||||||
|
self.assertFalse(self.user.is_active)
|
||||||
|
delete_event = UserAuthEvent.objects.get(
|
||||||
|
user=self.user,
|
||||||
|
event_type=UserAuthEvent.EventType.ACCOUNT_DELETED,
|
||||||
|
)
|
||||||
|
self.assertIn("soft-delete", delete_event.detail.lower())
|
||||||
|
self.assertTrue(
|
||||||
|
CustomUser.objects.filter(pk=self.user.pk, deleted=True).exists()
|
||||||
|
)
|
||||||
|
|
||||||
|
conversation.refresh_from_db()
|
||||||
|
self.assertTrue(conversation.deleted)
|
||||||
|
|
||||||
|
outstanding = OutstandingToken.objects.filter(user=self.user)
|
||||||
|
self.assertTrue(outstanding.exists())
|
||||||
|
for token in outstanding:
|
||||||
|
self.assertTrue(BlacklistedToken.objects.filter(token=token).exists())
|
||||||
|
|
||||||
|
# Soft-deleted users cannot obtain new tokens.
|
||||||
|
login = self.client.post(
|
||||||
|
reverse("token_create"),
|
||||||
|
{"username": self.user.username, "password": "testpass123"},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
self.assertEqual(login.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|
||||||
|
# Conversations list hides soft-deleted rows for any remaining session.
|
||||||
|
other = make_user(email="alive@example.com", company=self.company)
|
||||||
|
make_conversation(user=other, title="Still visible")
|
||||||
|
self.client.force_authenticate(user=other)
|
||||||
|
listed = self.client.get(reverse("conversations"))
|
||||||
|
titles = [row["title"] for row in listed.data]
|
||||||
|
self.assertNotIn("Keep hidden", titles)
|
||||||
|
|
||||||
|
def test_cannot_delete_another_user_via_body(self):
|
||||||
|
"""Endpoint always targets request.user; body email/id is ignored."""
|
||||||
|
other = make_user(email="other@example.com", company=self.company)
|
||||||
|
make_conversation(user=other, title="Other chat")
|
||||||
|
self.client.force_authenticate(user=self.user)
|
||||||
|
|
||||||
|
response = self.client.delete(
|
||||||
|
self.url,
|
||||||
|
{"email": other.email, "user_id": other.pk},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.user.refresh_from_db()
|
||||||
|
other.refresh_from_db()
|
||||||
|
self.assertTrue(self.user.deleted)
|
||||||
|
self.assertFalse(other.deleted)
|
||||||
|
self.assertFalse(
|
||||||
|
Conversation.objects.filter(user=other, deleted=True).exists()
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_staff_cannot_self_delete(self):
|
||||||
|
staff = make_user(
|
||||||
|
email="staff@example.com",
|
||||||
|
company=self.company,
|
||||||
|
is_staff=True,
|
||||||
|
)
|
||||||
|
self.client.force_authenticate(user=staff)
|
||||||
|
|
||||||
|
response = self.client.delete(self.url, format="json")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
self.assertEqual(response.data["code"], "staff_forbidden")
|
||||||
|
staff.refresh_from_db()
|
||||||
|
self.assertFalse(staff.deleted)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from .views import (
|
|||||||
CustomObtainTokenView,
|
CustomObtainTokenView,
|
||||||
CustomUserCreate,
|
CustomUserCreate,
|
||||||
CustomUserInvite,
|
CustomUserInvite,
|
||||||
|
CustomUserSelfDeleteView,
|
||||||
LogoutAndBlacklistRefreshTokenForUserView,
|
LogoutAndBlacklistRefreshTokenForUserView,
|
||||||
CustomUserGet,
|
CustomUserGet,
|
||||||
PublicSettingsView,
|
PublicSettingsView,
|
||||||
@@ -57,6 +58,7 @@ urlpatterns = [
|
|||||||
name="blacklist",
|
name="blacklist",
|
||||||
),
|
),
|
||||||
path("user/get/", CustomUserGet.as_view(), name="get_user"),
|
path("user/get/", CustomUserGet.as_view(), name="get_user"),
|
||||||
|
path("user/", CustomUserSelfDeleteView.as_view(), name="delete_user"),
|
||||||
path(
|
path(
|
||||||
"user/acknowledge_tos/",
|
"user/acknowledge_tos/",
|
||||||
AcknowledgeTermsOfService.as_view(),
|
AcknowledgeTermsOfService.as_view(),
|
||||||
|
|||||||
@@ -305,6 +305,48 @@ class CustomUserGet(APIView):
|
|||||||
return Response({}, status=status.HTTP_400_BAD_REQUEST)
|
return Response({}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
|
||||||
|
class CustomUserSelfDeleteView(APIView):
|
||||||
|
"""
|
||||||
|
Soft-delete the authenticated user's own account (#34).
|
||||||
|
|
||||||
|
Frontend contract:
|
||||||
|
- Method/path: ``DELETE /api/user/``
|
||||||
|
- Optional body: ``{"refresh_token": "<current refresh>"}`` to blacklist
|
||||||
|
the active session immediately (outstanding tokens are also blacklisted).
|
||||||
|
- Success: ``200`` with ``{"detail": "Account deleted.", "deleted": true}``
|
||||||
|
- After success: clear local tokens, redirect to sign-in. Subsequent
|
||||||
|
``/token/obtain/`` and authenticated calls fail (``is_active=False``,
|
||||||
|
``deleted=True``).
|
||||||
|
- Privacy v1: soft-delete only (no anonymization / hard purge).
|
||||||
|
"""
|
||||||
|
|
||||||
|
http_method_names = ["delete", "head", "options"]
|
||||||
|
|
||||||
|
def delete(self, request, format="json"):
|
||||||
|
from chat_backend.services.account_deletion import (
|
||||||
|
AccountDeletionError,
|
||||||
|
soft_delete_account,
|
||||||
|
)
|
||||||
|
|
||||||
|
refresh_token = request.data.get("refresh_token")
|
||||||
|
try:
|
||||||
|
soft_delete_account(
|
||||||
|
request.user,
|
||||||
|
refresh_token=refresh_token,
|
||||||
|
ip_address=_client_ip(request),
|
||||||
|
)
|
||||||
|
except AccountDeletionError as exc:
|
||||||
|
return Response(
|
||||||
|
{"detail": exc.detail, "code": exc.code},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
{"detail": "Account deleted.", "deleted": True},
|
||||||
|
status=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class FeedbackView(APIView):
|
class FeedbackView(APIView):
|
||||||
http_method_names = ["post", "get"]
|
http_method_names = ["post", "get"]
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# Generated by Django 6.0 on 2026-08-01 19:11
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("finance", "0002_subscription_plans_quotas"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="usersubscription",
|
||||||
|
name="cancel_at_period_end",
|
||||||
|
field=models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
help_text="Stripe: subscription will cancel at current_period_end.",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="usersubscription",
|
||||||
|
name="current_period_end",
|
||||||
|
field=models.DateTimeField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Stripe billing period end (access remains until then when canceling).",
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -149,6 +149,15 @@ class UserSubscription(TimeInfoBase):
|
|||||||
default="",
|
default="",
|
||||||
db_index=True,
|
db_index=True,
|
||||||
)
|
)
|
||||||
|
cancel_at_period_end = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
help_text="Stripe: subscription will cancel at current_period_end.",
|
||||||
|
)
|
||||||
|
current_period_end = models.DateTimeField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="Stripe billing period end (access remains until then when canceling).",
|
||||||
|
)
|
||||||
monthly_token_quota_override = models.PositiveIntegerField(
|
monthly_token_quota_override = models.PositiveIntegerField(
|
||||||
null=True,
|
null=True,
|
||||||
blank=True,
|
blank=True,
|
||||||
|
|||||||
@@ -8,10 +8,25 @@ from typing import Any
|
|||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from chat_backend.models import UserAuthEvent
|
||||||
from finance.models import BackerEmail, SubscriptionPlan, UserSubscription
|
from finance.models import BackerEmail, SubscriptionPlan, UserSubscription
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def log_subscription_auth_event(
|
||||||
|
user,
|
||||||
|
*,
|
||||||
|
started: bool,
|
||||||
|
detail: str,
|
||||||
|
) -> None:
|
||||||
|
event_type = (
|
||||||
|
UserAuthEvent.EventType.SUBSCRIPTION_STARTED
|
||||||
|
if started
|
||||||
|
else UserAuthEvent.EventType.SUBSCRIPTION_UPDATED
|
||||||
|
)
|
||||||
|
UserAuthEvent.log(user, event_type, detail=detail[:512])
|
||||||
|
|
||||||
# Seed catalog for #36. Standard/Pro/Business stay hidden until explicitly enabled.
|
# Seed catalog for #36. Standard/Pro/Business stay hidden until explicitly enabled.
|
||||||
PLAN_SEED: list[dict[str, Any]] = [
|
PLAN_SEED: list[dict[str, Any]] = [
|
||||||
{
|
{
|
||||||
@@ -141,14 +156,63 @@ def assign_plan(
|
|||||||
source: str,
|
source: str,
|
||||||
status: str = UserSubscription.Status.ACTIVE,
|
status: str = UserSubscription.Status.ACTIVE,
|
||||||
stripe_subscription_id: str = "",
|
stripe_subscription_id: str = "",
|
||||||
|
log_auth_event: bool = True,
|
||||||
) -> UserSubscription:
|
) -> UserSubscription:
|
||||||
sub = get_or_create_user_subscription(user)
|
sub = get_or_create_user_subscription(user)
|
||||||
|
prev_plan_id = sub.plan_id
|
||||||
|
prev_status = sub.status
|
||||||
|
prev_source = sub.source
|
||||||
|
prev_stripe_sub = sub.stripe_subscription_id or ""
|
||||||
|
had_active = (
|
||||||
|
prev_status == UserSubscription.Status.ACTIVE and prev_plan_id is not None
|
||||||
|
)
|
||||||
|
|
||||||
sub.plan = plan
|
sub.plan = plan
|
||||||
sub.source = source
|
sub.source = source
|
||||||
sub.status = status
|
sub.status = status
|
||||||
if stripe_subscription_id:
|
if stripe_subscription_id:
|
||||||
sub.stripe_subscription_id = stripe_subscription_id
|
sub.stripe_subscription_id = stripe_subscription_id
|
||||||
sub.save()
|
sub.save()
|
||||||
|
|
||||||
|
if log_auth_event:
|
||||||
|
became_active = (
|
||||||
|
status == UserSubscription.Status.ACTIVE and plan is not None
|
||||||
|
)
|
||||||
|
changed = (
|
||||||
|
prev_plan_id != sub.plan_id
|
||||||
|
or prev_status != sub.status
|
||||||
|
or prev_source != sub.source
|
||||||
|
or (
|
||||||
|
bool(stripe_subscription_id)
|
||||||
|
and prev_stripe_sub != (sub.stripe_subscription_id or "")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if became_active and not had_active:
|
||||||
|
log_subscription_auth_event(
|
||||||
|
user,
|
||||||
|
started=True,
|
||||||
|
detail=(
|
||||||
|
f"plan={plan.slug} source={source} status={status}"
|
||||||
|
+ (
|
||||||
|
f" stripe_subscription_id={stripe_subscription_id}"
|
||||||
|
if stripe_subscription_id
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
elif changed:
|
||||||
|
log_subscription_auth_event(
|
||||||
|
user,
|
||||||
|
started=False,
|
||||||
|
detail=(
|
||||||
|
f"plan={plan.slug} source={source} status={status}"
|
||||||
|
+ (
|
||||||
|
f" stripe_subscription_id={stripe_subscription_id}"
|
||||||
|
if stripe_subscription_id
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
return sub
|
return sub
|
||||||
|
|
||||||
|
|
||||||
@@ -221,17 +285,119 @@ def assign_founders_from_stripe(
|
|||||||
*,
|
*,
|
||||||
stripe_subscription_id: str = "",
|
stripe_subscription_id: str = "",
|
||||||
) -> UserSubscription:
|
) -> UserSubscription:
|
||||||
|
"""Backward-compatible helper; prefer ``assign_plan_from_stripe``."""
|
||||||
|
return assign_plan_from_stripe(
|
||||||
|
user,
|
||||||
|
plan_slug=SubscriptionPlan.Slug.FOUNDERS,
|
||||||
|
stripe_subscription_id=stripe_subscription_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def assign_plan_from_stripe(
|
||||||
|
user,
|
||||||
|
*,
|
||||||
|
plan_slug: str | None = None,
|
||||||
|
stripe_subscription_id: str = "",
|
||||||
|
status: str = UserSubscription.Status.ACTIVE,
|
||||||
|
cancel_at_period_end: bool | None = None,
|
||||||
|
current_period_end=None,
|
||||||
|
keep_existing_plan_if_unknown: bool = False,
|
||||||
|
) -> UserSubscription:
|
||||||
|
"""Assign a catalog plan from a Stripe Checkout / subscription event."""
|
||||||
seed_subscription_plans(update_existing=False)
|
seed_subscription_plans(update_existing=False)
|
||||||
plan = get_plan(SubscriptionPlan.Slug.FOUNDERS)
|
existing = (
|
||||||
|
UserSubscription.objects.filter(user=user).select_related("plan").first()
|
||||||
|
)
|
||||||
|
prev_cancel = bool(existing.cancel_at_period_end) if existing else False
|
||||||
|
prev_period_end = existing.current_period_end if existing else None
|
||||||
|
had_active = bool(
|
||||||
|
existing
|
||||||
|
and existing.status == UserSubscription.Status.ACTIVE
|
||||||
|
and existing.plan_id
|
||||||
|
)
|
||||||
|
|
||||||
|
slug = (plan_slug or "").strip().lower()
|
||||||
|
plan = get_plan(slug) if slug else None
|
||||||
|
if plan is None and keep_existing_plan_if_unknown and existing and existing.plan_id:
|
||||||
|
plan = existing.plan
|
||||||
|
if plan is None:
|
||||||
|
if slug:
|
||||||
|
logger.warning(
|
||||||
|
"Unknown plan_slug=%s; falling back to Founders for user=%s",
|
||||||
|
slug,
|
||||||
|
getattr(user, "pk", None),
|
||||||
|
)
|
||||||
|
plan = get_plan(SubscriptionPlan.Slug.FOUNDERS)
|
||||||
if plan is None:
|
if plan is None:
|
||||||
raise RuntimeError("Founders plan missing from catalog")
|
raise RuntimeError("Founders plan missing from catalog")
|
||||||
return assign_plan(
|
|
||||||
|
# Single auth-event log after plan + cancel fields are applied.
|
||||||
|
sub = assign_plan(
|
||||||
user,
|
user,
|
||||||
plan=plan,
|
plan=plan,
|
||||||
source=UserSubscription.Source.STRIPE,
|
source=UserSubscription.Source.STRIPE,
|
||||||
status=UserSubscription.Status.ACTIVE,
|
status=status,
|
||||||
stripe_subscription_id=stripe_subscription_id or "",
|
stripe_subscription_id=stripe_subscription_id or "",
|
||||||
|
log_auth_event=False,
|
||||||
)
|
)
|
||||||
|
update_fields: list[str] = []
|
||||||
|
if cancel_at_period_end is not None:
|
||||||
|
sub.cancel_at_period_end = bool(cancel_at_period_end)
|
||||||
|
update_fields.append("cancel_at_period_end")
|
||||||
|
if current_period_end is not None:
|
||||||
|
sub.current_period_end = current_period_end
|
||||||
|
update_fields.append("current_period_end")
|
||||||
|
if update_fields:
|
||||||
|
sub.save(update_fields=update_fields)
|
||||||
|
|
||||||
|
became_active = (
|
||||||
|
sub.status == UserSubscription.Status.ACTIVE and sub.plan_id is not None
|
||||||
|
)
|
||||||
|
cancel_changed = (
|
||||||
|
cancel_at_period_end is not None
|
||||||
|
and bool(cancel_at_period_end) != prev_cancel
|
||||||
|
)
|
||||||
|
period_changed = (
|
||||||
|
current_period_end is not None and current_period_end != prev_period_end
|
||||||
|
)
|
||||||
|
plan_or_status_changed = (
|
||||||
|
not existing
|
||||||
|
or existing.plan_id != sub.plan_id
|
||||||
|
or existing.status != sub.status
|
||||||
|
or (existing.source != sub.source)
|
||||||
|
or (
|
||||||
|
bool(stripe_subscription_id)
|
||||||
|
and (existing.stripe_subscription_id or "")
|
||||||
|
!= (sub.stripe_subscription_id or "")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if became_active and not had_active:
|
||||||
|
log_subscription_auth_event(
|
||||||
|
user,
|
||||||
|
started=True,
|
||||||
|
detail=(
|
||||||
|
f"plan={sub.plan.slug} source={sub.source} status={sub.status}"
|
||||||
|
f" cancel_at_period_end={sub.cancel_at_period_end}"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
elif plan_or_status_changed or cancel_changed or period_changed:
|
||||||
|
log_subscription_auth_event(
|
||||||
|
user,
|
||||||
|
started=False,
|
||||||
|
detail=(
|
||||||
|
f"plan={sub.plan.slug if sub.plan_id else 'none'} "
|
||||||
|
f"source={sub.source} status={sub.status} "
|
||||||
|
f"cancel_at_period_end={sub.cancel_at_period_end}"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return sub
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_plan_from_stripe_price(price_id: str | None) -> SubscriptionPlan | None:
|
||||||
|
"""Map a Stripe Price id to a local SubscriptionPlan when configured."""
|
||||||
|
if not price_id:
|
||||||
|
return None
|
||||||
|
return SubscriptionPlan.objects.filter(stripe_price_id=price_id).first()
|
||||||
|
|
||||||
|
|
||||||
def plan_to_dict(plan: SubscriptionPlan | None) -> dict[str, Any] | None:
|
def plan_to_dict(plan: SubscriptionPlan | None) -> dict[str, Any] | None:
|
||||||
|
|||||||
@@ -10,13 +10,68 @@ from django.contrib.auth import get_user_model
|
|||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from finance.models import Invoice, Payment
|
from finance.models import Invoice, Payment, UserSubscription
|
||||||
from finance.services.plans import assign_founders_from_stripe
|
from finance.services.plans import (
|
||||||
|
assign_plan_from_stripe,
|
||||||
|
get_or_create_user_subscription,
|
||||||
|
log_subscription_auth_event,
|
||||||
|
resolve_plan_from_stripe_price,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
def _stripe_status_to_local(stripe_status: str | None) -> str:
|
||||||
|
mapping = {
|
||||||
|
"active": UserSubscription.Status.ACTIVE,
|
||||||
|
"trialing": UserSubscription.Status.ACTIVE,
|
||||||
|
"past_due": UserSubscription.Status.PAST_DUE,
|
||||||
|
"unpaid": UserSubscription.Status.PAST_DUE,
|
||||||
|
"canceled": UserSubscription.Status.CANCELED,
|
||||||
|
"incomplete_expired": UserSubscription.Status.CANCELED,
|
||||||
|
}
|
||||||
|
return mapping.get((stripe_status or "").lower(), UserSubscription.Status.NONE)
|
||||||
|
|
||||||
|
|
||||||
|
def _plan_slug_from_subscription(subscription: dict[str, Any]) -> str | None:
|
||||||
|
metadata = subscription.get("metadata") or {}
|
||||||
|
if metadata.get("plan_slug"):
|
||||||
|
return metadata.get("plan_slug")
|
||||||
|
items = (subscription.get("items") or {}).get("data") or []
|
||||||
|
if not items:
|
||||||
|
return None
|
||||||
|
price = (items[0] or {}).get("price") or {}
|
||||||
|
price_id = price.get("id") if isinstance(price, dict) else None
|
||||||
|
plan = resolve_plan_from_stripe_price(price_id)
|
||||||
|
return plan.slug if plan else None
|
||||||
|
|
||||||
|
|
||||||
|
def _user_from_subscription(subscription: dict[str, Any]):
|
||||||
|
metadata = subscription.get("metadata") or {}
|
||||||
|
user = _user_from_metadata(metadata)
|
||||||
|
if user is not None:
|
||||||
|
return user
|
||||||
|
sub_id = subscription.get("id")
|
||||||
|
if sub_id:
|
||||||
|
existing = (
|
||||||
|
Invoice.objects.filter(stripe_subscription_id=sub_id)
|
||||||
|
.select_related("user")
|
||||||
|
.order_by("-created")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
return existing.user
|
||||||
|
local_sub = (
|
||||||
|
UserSubscription.objects.filter(stripe_subscription_id=sub_id)
|
||||||
|
.select_related("user")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if local_sub:
|
||||||
|
return local_sub.user
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _ts_to_dt(value: int | None):
|
def _ts_to_dt(value: int | None):
|
||||||
if not value:
|
if not value:
|
||||||
return None
|
return None
|
||||||
@@ -213,8 +268,9 @@ def handle_checkout_session_completed(session: dict[str, Any]) -> Invoice | None
|
|||||||
paid_at=timezone.now(),
|
paid_at=timezone.now(),
|
||||||
)
|
)
|
||||||
if session.get("payment_status") == "paid" or session.get("subscription"):
|
if session.get("payment_status") == "paid" or session.get("subscription"):
|
||||||
assign_founders_from_stripe(
|
assign_plan_from_stripe(
|
||||||
user,
|
user,
|
||||||
|
plan_slug=metadata.get("plan_slug"),
|
||||||
stripe_subscription_id=session.get("subscription") or "",
|
stripe_subscription_id=session.get("subscription") or "",
|
||||||
)
|
)
|
||||||
return invoice
|
return invoice
|
||||||
@@ -276,8 +332,9 @@ def handle_invoice_paid(stripe_invoice: dict[str, Any]) -> Invoice | None:
|
|||||||
stripe_charge_id=charge if isinstance(charge, str) else None,
|
stripe_charge_id=charge if isinstance(charge, str) else None,
|
||||||
paid_at=paid_at,
|
paid_at=paid_at,
|
||||||
)
|
)
|
||||||
assign_founders_from_stripe(
|
assign_plan_from_stripe(
|
||||||
user,
|
user,
|
||||||
|
plan_slug=metadata.get("plan_slug"),
|
||||||
stripe_subscription_id=stripe_invoice.get("subscription") or "",
|
stripe_subscription_id=stripe_invoice.get("subscription") or "",
|
||||||
)
|
)
|
||||||
return invoice
|
return invoice
|
||||||
@@ -326,6 +383,68 @@ def handle_invoice_payment_failed(stripe_invoice: dict[str, Any]) -> Invoice | N
|
|||||||
return invoice
|
return invoice
|
||||||
|
|
||||||
|
|
||||||
|
def handle_customer_subscription_updated(subscription: dict[str, Any]):
|
||||||
|
"""Sync local UserSubscription after portal plan change / cancel schedule."""
|
||||||
|
user = _user_from_subscription(subscription)
|
||||||
|
if user is None:
|
||||||
|
logger.error(
|
||||||
|
"customer.subscription.updated: cannot resolve user for %s",
|
||||||
|
subscription.get("id"),
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
local_status = _stripe_status_to_local(subscription.get("status"))
|
||||||
|
if subscription.get("cancel_at_period_end") and local_status == (
|
||||||
|
UserSubscription.Status.ACTIVE
|
||||||
|
):
|
||||||
|
# Still active until period end; keep ACTIVE and surface cancel flag.
|
||||||
|
pass
|
||||||
|
|
||||||
|
return assign_plan_from_stripe(
|
||||||
|
user,
|
||||||
|
plan_slug=_plan_slug_from_subscription(subscription),
|
||||||
|
stripe_subscription_id=subscription.get("id") or "",
|
||||||
|
status=local_status or UserSubscription.Status.ACTIVE,
|
||||||
|
cancel_at_period_end=bool(subscription.get("cancel_at_period_end")),
|
||||||
|
current_period_end=_ts_to_dt(subscription.get("current_period_end")),
|
||||||
|
keep_existing_plan_if_unknown=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def handle_customer_subscription_deleted(subscription: dict[str, Any]):
|
||||||
|
"""Mark local subscription canceled when Stripe subscription ends."""
|
||||||
|
user = _user_from_subscription(subscription)
|
||||||
|
if user is None:
|
||||||
|
logger.error(
|
||||||
|
"customer.subscription.deleted: cannot resolve user for %s",
|
||||||
|
subscription.get("id"),
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
sub = get_or_create_user_subscription(user)
|
||||||
|
prev_status = sub.status
|
||||||
|
sub.status = UserSubscription.Status.CANCELED
|
||||||
|
sub.cancel_at_period_end = False
|
||||||
|
sub.current_period_end = _ts_to_dt(subscription.get("current_period_end"))
|
||||||
|
if subscription.get("id"):
|
||||||
|
sub.stripe_subscription_id = subscription["id"]
|
||||||
|
# Preserve plan so UI can show what ended; source stays stripe.
|
||||||
|
if sub.source == UserSubscription.Source.NONE:
|
||||||
|
sub.source = UserSubscription.Source.STRIPE
|
||||||
|
sub.save()
|
||||||
|
if prev_status != UserSubscription.Status.CANCELED:
|
||||||
|
log_subscription_auth_event(
|
||||||
|
user,
|
||||||
|
started=False,
|
||||||
|
detail=(
|
||||||
|
f"plan={sub.plan.slug if sub.plan_id else 'none'} "
|
||||||
|
f"source={sub.source} status={sub.status} "
|
||||||
|
f"stripe_subscription_id={sub.stripe_subscription_id}"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return sub
|
||||||
|
|
||||||
|
|
||||||
def dispatch_stripe_event(event: dict[str, Any]):
|
def dispatch_stripe_event(event: dict[str, Any]):
|
||||||
"""Route a verified Stripe event to the appropriate handler."""
|
"""Route a verified Stripe event to the appropriate handler."""
|
||||||
event_type = event.get("type")
|
event_type = event.get("type")
|
||||||
@@ -337,6 +456,10 @@ def dispatch_stripe_event(event: dict[str, Any]):
|
|||||||
return handle_invoice_paid(data_object)
|
return handle_invoice_paid(data_object)
|
||||||
if event_type == "invoice.payment_failed":
|
if event_type == "invoice.payment_failed":
|
||||||
return handle_invoice_payment_failed(data_object)
|
return handle_invoice_payment_failed(data_object)
|
||||||
|
if event_type == "customer.subscription.updated":
|
||||||
|
return handle_customer_subscription_updated(data_object)
|
||||||
|
if event_type == "customer.subscription.deleted":
|
||||||
|
return handle_customer_subscription_deleted(data_object)
|
||||||
|
|
||||||
logger.info("Ignoring unhandled Stripe event type: %s", event_type)
|
logger.info("Ignoring unhandled Stripe event type: %s", event_type)
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -8,10 +8,14 @@ from rest_framework import status
|
|||||||
from rest_framework.test import APITestCase
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
from chat_backend.tests.factories import make_company, make_user
|
from chat_backend.tests.factories import make_company, make_user
|
||||||
from finance.models import Invoice, Payment
|
from chat_backend.models import UserAuthEvent
|
||||||
|
from finance.models import Invoice, Payment, UserSubscription
|
||||||
|
from finance.services.plans import assign_plan_from_stripe, seed_subscription_plans
|
||||||
from finance.services.webhooks import (
|
from finance.services.webhooks import (
|
||||||
dispatch_stripe_event,
|
dispatch_stripe_event,
|
||||||
handle_checkout_session_completed,
|
handle_checkout_session_completed,
|
||||||
|
handle_customer_subscription_deleted,
|
||||||
|
handle_customer_subscription_updated,
|
||||||
handle_invoice_paid,
|
handle_invoice_paid,
|
||||||
handle_invoice_payment_failed,
|
handle_invoice_payment_failed,
|
||||||
)
|
)
|
||||||
@@ -107,6 +111,81 @@ class WebhookHandlerUnitTestCase(APITestCase):
|
|||||||
)
|
)
|
||||||
self.assertIsNone(result)
|
self.assertIsNone(result)
|
||||||
|
|
||||||
|
def test_checkout_assigns_plan_from_metadata(self):
|
||||||
|
seed_subscription_plans(update_existing=False)
|
||||||
|
session = {
|
||||||
|
"id": "cs_test_plan_meta",
|
||||||
|
"metadata": {"user_id": str(self.user.pk), "plan_slug": "founders"},
|
||||||
|
"customer": "cus_meta",
|
||||||
|
"subscription": "sub_meta",
|
||||||
|
"payment_intent": "pi_meta",
|
||||||
|
"payment_status": "paid",
|
||||||
|
"amount_total": 1000,
|
||||||
|
"currency": "usd",
|
||||||
|
}
|
||||||
|
handle_checkout_session_completed(session)
|
||||||
|
sub = UserSubscription.objects.get(user=self.user)
|
||||||
|
self.assertEqual(sub.plan.slug, "founders")
|
||||||
|
self.assertEqual(sub.source, UserSubscription.Source.STRIPE)
|
||||||
|
self.assertEqual(sub.status, UserSubscription.Status.ACTIVE)
|
||||||
|
started = UserAuthEvent.objects.get(
|
||||||
|
user=self.user,
|
||||||
|
event_type=UserAuthEvent.EventType.SUBSCRIPTION_STARTED,
|
||||||
|
)
|
||||||
|
self.assertIn("founders", started.detail)
|
||||||
|
|
||||||
|
def test_subscription_updated_sets_cancel_at_period_end(self):
|
||||||
|
seed_subscription_plans(update_existing=False)
|
||||||
|
assign_plan_from_stripe(
|
||||||
|
self.user,
|
||||||
|
plan_slug="founders",
|
||||||
|
stripe_subscription_id="sub_cancel",
|
||||||
|
)
|
||||||
|
result = handle_customer_subscription_updated(
|
||||||
|
{
|
||||||
|
"id": "sub_cancel",
|
||||||
|
"status": "active",
|
||||||
|
"cancel_at_period_end": True,
|
||||||
|
"current_period_end": 1_700_259_200,
|
||||||
|
"metadata": {"user_id": str(self.user.pk), "plan_slug": "founders"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(result)
|
||||||
|
sub = UserSubscription.objects.get(user=self.user)
|
||||||
|
self.assertTrue(sub.cancel_at_period_end)
|
||||||
|
self.assertEqual(sub.status, UserSubscription.Status.ACTIVE)
|
||||||
|
self.assertIsNotNone(sub.current_period_end)
|
||||||
|
updated = UserAuthEvent.objects.filter(
|
||||||
|
user=self.user,
|
||||||
|
event_type=UserAuthEvent.EventType.SUBSCRIPTION_UPDATED,
|
||||||
|
).latest("created")
|
||||||
|
self.assertIn("cancel_at_period_end=True", updated.detail)
|
||||||
|
|
||||||
|
def test_subscription_deleted_marks_canceled(self):
|
||||||
|
seed_subscription_plans(update_existing=False)
|
||||||
|
assign_plan_from_stripe(
|
||||||
|
self.user,
|
||||||
|
plan_slug="founders",
|
||||||
|
stripe_subscription_id="sub_gone",
|
||||||
|
)
|
||||||
|
result = handle_customer_subscription_deleted(
|
||||||
|
{
|
||||||
|
"id": "sub_gone",
|
||||||
|
"status": "canceled",
|
||||||
|
"current_period_end": 1_700_259_200,
|
||||||
|
"metadata": {"user_id": str(self.user.pk)},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(result)
|
||||||
|
sub = UserSubscription.objects.get(user=self.user)
|
||||||
|
self.assertEqual(sub.status, UserSubscription.Status.CANCELED)
|
||||||
|
self.assertFalse(sub.cancel_at_period_end)
|
||||||
|
updated = UserAuthEvent.objects.filter(
|
||||||
|
user=self.user,
|
||||||
|
event_type=UserAuthEvent.EventType.SUBSCRIPTION_UPDATED,
|
||||||
|
).latest("created")
|
||||||
|
self.assertIn("status=canceled", updated.detail)
|
||||||
|
|
||||||
|
|
||||||
class StripeWebhookViewTestCase(APITestCase):
|
class StripeWebhookViewTestCase(APITestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
|
|||||||
@@ -199,6 +199,12 @@ class SubscriptionMeView(APIView):
|
|||||||
"stripe_subscription_id": (
|
"stripe_subscription_id": (
|
||||||
sub.stripe_subscription_id if sub else ""
|
sub.stripe_subscription_id if sub else ""
|
||||||
),
|
),
|
||||||
|
"cancel_at_period_end": bool(sub.cancel_at_period_end) if sub else False,
|
||||||
|
"current_period_end": (
|
||||||
|
sub.current_period_end.isoformat()
|
||||||
|
if sub and sub.current_period_end
|
||||||
|
else None
|
||||||
|
),
|
||||||
"usage": usage.to_dict(),
|
"usage": usage.to_dict(),
|
||||||
}
|
}
|
||||||
return Response(payload)
|
return Response(payload)
|
||||||
|
|||||||
Reference in New Issue
Block a user