Add account self-delete and subscription lifecycle sync (#34) (#39)
Deploy Beta / unit-tests (push) Successful in 10s
Unit Tests / test (push) Successful in 10s
Deploy Beta / docker (push) Successful in 26s
Deploy Beta / deploy-beta (push) Successful in 6m46s

## 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:
2026-08-01 12:24:17 -07:00
parent cc45ae5808
commit eedc842b08
14 changed files with 740 additions and 10 deletions
@@ -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,
),
),
]
+4 -1
View File
@@ -82,7 +82,7 @@ class CustomUser(AbstractUser):
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):
PASSWORD_RESET_REQUESTED = (
@@ -91,6 +91,9 @@ class UserAuthEvent(models.Model):
)
PASSWORD_SET = ("password_set", "Password set")
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(
CustomUser,
+17
View File
@@ -26,6 +26,17 @@ class MyTokenObtainPairSerializer(TokenObtainPairSerializer):
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 Meta:
@@ -70,12 +81,18 @@ class CustomUserSerializer(serializers.ModelSerializer):
"status": UserSubscription.Status.NONE,
"source": UserSubscription.Source.NONE,
"needs_checkout": True,
"cancel_at_period_end": False,
"current_period_end": None,
}
return {
"plan": plan_to_dict(sub.plan) if sub.plan_id else None,
"status": sub.status,
"source": sub.source,
"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
+105 -1
View File
@@ -7,13 +7,18 @@ from rest_framework_simplejwt.tokens import RefreshToken
from chat_backend.models import (
Announcement,
Conversation,
CustomUser,
Feedback,
OutboundEmail,
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):
@@ -520,3 +525,102 @@ class CustomUserGetTestCase(APITestCase):
self.assertEqual(response.data["email"], user.email)
self.assertEqual(response.data["company"]["name"], "Globex")
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)
+2
View File
@@ -5,6 +5,7 @@ from .views import (
CustomObtainTokenView,
CustomUserCreate,
CustomUserInvite,
CustomUserSelfDeleteView,
LogoutAndBlacklistRefreshTokenForUserView,
CustomUserGet,
PublicSettingsView,
@@ -57,6 +58,7 @@ urlpatterns = [
name="blacklist",
),
path("user/get/", CustomUserGet.as_view(), name="get_user"),
path("user/", CustomUserSelfDeleteView.as_view(), name="delete_user"),
path(
"user/acknowledge_tos/",
AcknowledgeTermsOfService.as_view(),
+42
View File
@@ -305,6 +305,48 @@ class CustomUserGet(APIView):
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):
http_method_names = ["post", "get"]