Log account delete and subscription changes in UserAuthEvent
CI / test (pull_request) Successful in 10s
Unit Tests / test (pull_request) Successful in 10s

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.
This commit is contained in:
2026-08-01 14:21:53 -05:00
parent b3203f755d
commit d01f3a2696
9 changed files with 206 additions and 9 deletions
+121 -4
View File
@@ -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
+12
View File
@@ -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
+17 -1
View File
@@ -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):