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
+29
View File
@@ -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,
),
),
]
+4 -1
View File
@@ -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,
+17
View File
@@ -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
+105 -1
View File
@@ -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)
+2
View File
@@ -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(),
+42
View File
@@ -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,
),
),
]
+9
View File
@@ -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,
+168 -2
View File
@@ -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)
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) 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:
+127 -4
View File
@@ -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
+80 -1
View File
@@ -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):
+6
View File
@@ -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)