From d01f3a269675c63cc0fe054fccdb1b32ba6de8c3 Mon Sep 17 00:00:00 2001 From: Ryan Westfall Date: Sat, 1 Aug 2026 14:21:53 -0500 Subject: [PATCH] Log account delete and subscription changes in UserAuthEvent Add account_deleted, subscription_started, and subscription_updated event types. Soft-delete, Checkout/Backer assign, and Stripe portal lifecycle webhooks write audit rows visible on the user admin. --- README.md | 6 +- ...user_auth_event_subscription_and_delete.py | 28 ++++ llm_be/chat_backend/models.py | 5 +- .../chat_backend/services/account_deletion.py | 10 +- llm_be/chat_backend/tests/test_views_users.py | 5 + llm_be/chat_backend/views.py | 6 +- llm_be/finance/services/plans.py | 125 +++++++++++++++++- llm_be/finance/services/webhooks.py | 12 ++ llm_be/finance/tests/test_webhooks.py | 18 ++- 9 files changed, 206 insertions(+), 9 deletions(-) create mode 100644 llm_be/chat_backend/migrations/0027_user_auth_event_subscription_and_delete.py diff --git a/README.md b/README.md index fd75fb2..f644d9e 100644 --- a/README.md +++ b/README.md @@ -190,7 +190,7 @@ Push/merge to `master` auto-deploys **beta** only. Prod requires the Gitea | Auth | JWT (authenticated user only; always deletes `request.user`) | | Optional body | `{ "refresh_token": "" }` | | Success | `200` `{ "detail": "Account deleted.", "deleted": true }` | -| Effects | Sets `deleted=True`, `is_active=False`; soft-deletes conversations; blacklists outstanding refresh tokens | +| 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) | @@ -205,6 +205,10 @@ Plan change and cancel stay on Stripe Customer Portal `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 Secrets previously hardcoded in `settings.py` (email password, captcha, Django diff --git a/llm_be/chat_backend/migrations/0027_user_auth_event_subscription_and_delete.py b/llm_be/chat_backend/migrations/0027_user_auth_event_subscription_and_delete.py new file mode 100644 index 0000000..c40c554 --- /dev/null +++ b/llm_be/chat_backend/migrations/0027_user_auth_event_subscription_and_delete.py @@ -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, + ), + ), + ] diff --git a/llm_be/chat_backend/models.py b/llm_be/chat_backend/models.py index 60b4e5a..8a692a8 100644 --- a/llm_be/chat_backend/models.py +++ b/llm_be/chat_backend/models.py @@ -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, diff --git a/llm_be/chat_backend/services/account_deletion.py b/llm_be/chat_backend/services/account_deletion.py index b69793f..148aeaa 100644 --- a/llm_be/chat_backend/services/account_deletion.py +++ b/llm_be/chat_backend/services/account_deletion.py @@ -11,7 +11,7 @@ from rest_framework_simplejwt.token_blacklist.models import ( ) from rest_framework_simplejwt.tokens import RefreshToken -from chat_backend.models import Conversation, CustomUser +from chat_backend.models import Conversation, CustomUser, UserAuthEvent logger = logging.getLogger(__name__) @@ -49,6 +49,7 @@ 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. @@ -74,6 +75,13 @@ def soft_delete_account( 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( diff --git a/llm_be/chat_backend/tests/test_views_users.py b/llm_be/chat_backend/tests/test_views_users.py index 4468a35..3748610 100644 --- a/llm_be/chat_backend/tests/test_views_users.py +++ b/llm_be/chat_backend/tests/test_views_users.py @@ -556,6 +556,11 @@ class CustomUserSelfDeleteTestCase(APITestCase): 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() ) diff --git a/llm_be/chat_backend/views.py b/llm_be/chat_backend/views.py index f69b5a3..1c23dfb 100644 --- a/llm_be/chat_backend/views.py +++ b/llm_be/chat_backend/views.py @@ -330,7 +330,11 @@ class CustomUserSelfDeleteView(APIView): refresh_token = request.data.get("refresh_token") try: - soft_delete_account(request.user, refresh_token=refresh_token) + 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}, diff --git a/llm_be/finance/services/plans.py b/llm_be/finance/services/plans.py index a52b16e..e2cad08 100644 --- a/llm_be/finance/services/plans.py +++ b/llm_be/finance/services/plans.py @@ -8,10 +8,25 @@ from typing import Any from django.db import transaction from django.utils import timezone +from chat_backend.models import UserAuthEvent from finance.models import BackerEmail, SubscriptionPlan, UserSubscription 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. PLAN_SEED: list[dict[str, Any]] = [ { @@ -141,14 +156,63 @@ def assign_plan( source: str, status: str = UserSubscription.Status.ACTIVE, stripe_subscription_id: str = "", + log_auth_event: bool = True, ) -> UserSubscription: 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.source = source sub.status = status if stripe_subscription_id: sub.stripe_subscription_id = stripe_subscription_id 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 @@ -241,12 +305,21 @@ def assign_plan_from_stripe( ) -> UserSubscription: """Assign a catalog plan from a Stripe Checkout / subscription event.""" 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: - existing = UserSubscription.objects.filter(user=user).select_related("plan").first() - if existing and existing.plan_id: - plan = existing.plan + 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( @@ -257,12 +330,15 @@ def assign_plan_from_stripe( plan = get_plan(SubscriptionPlan.Slug.FOUNDERS) if plan is None: raise RuntimeError("Founders plan missing from catalog") + + # Single auth-event log after plan + cancel fields are applied. sub = assign_plan( user, plan=plan, source=UserSubscription.Source.STRIPE, status=status, stripe_subscription_id=stripe_subscription_id or "", + log_auth_event=False, ) update_fields: list[str] = [] if cancel_at_period_end is not None: @@ -273,6 +349,47 @@ def assign_plan_from_stripe( 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 diff --git a/llm_be/finance/services/webhooks.py b/llm_be/finance/services/webhooks.py index 6044bc7..90be95f 100644 --- a/llm_be/finance/services/webhooks.py +++ b/llm_be/finance/services/webhooks.py @@ -14,6 +14,7 @@ from finance.models import Invoice, Payment, UserSubscription from finance.services.plans import ( assign_plan_from_stripe, get_or_create_user_subscription, + log_subscription_auth_event, resolve_plan_from_stripe_price, ) @@ -421,6 +422,7 @@ def handle_customer_subscription_deleted(subscription: dict[str, Any]): 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")) @@ -430,6 +432,16 @@ def handle_customer_subscription_deleted(subscription: dict[str, Any]): 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 diff --git a/llm_be/finance/tests/test_webhooks.py b/llm_be/finance/tests/test_webhooks.py index 8f2e039..9acc3bb 100644 --- a/llm_be/finance/tests/test_webhooks.py +++ b/llm_be/finance/tests/test_webhooks.py @@ -8,6 +8,7 @@ from rest_framework import status from rest_framework.test import APITestCase from chat_backend.tests.factories import make_company, make_user +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 ( @@ -123,10 +124,15 @@ class WebhookHandlerUnitTestCase(APITestCase): "currency": "usd", } handle_checkout_session_completed(session) - sub = self.user.subscription + 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) @@ -149,6 +155,11 @@ class WebhookHandlerUnitTestCase(APITestCase): 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) @@ -169,6 +180,11 @@ class WebhookHandlerUnitTestCase(APITestCase): 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):