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,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