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.
This commit is contained in:
@@ -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`) |
|
| Auth | JWT (authenticated user only; always deletes `request.user`) |
|
||||||
| Optional body | `{ "refresh_token": "<current refresh>" }` |
|
| Optional body | `{ "refresh_token": "<current refresh>" }` |
|
||||||
| Success | `200` `{ "detail": "Account deleted.", "deleted": true }` |
|
| 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`) |
|
| Staff | Staff/superuser self-delete rejected (`400`, `code=staff_forbidden`) |
|
||||||
| Privacy v1 | Soft-delete only (no anonymization / hard purge) |
|
| 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
|
`GET /api/finance/subscription/` includes `cancel_at_period_end` and
|
||||||
`current_period_end` for Account UI messaging.
|
`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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from rest_framework_simplejwt.token_blacklist.models import (
|
|||||||
)
|
)
|
||||||
from rest_framework_simplejwt.tokens import RefreshToken
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -49,6 +49,7 @@ def soft_delete_account(
|
|||||||
user: CustomUser,
|
user: CustomUser,
|
||||||
*,
|
*,
|
||||||
refresh_token: str | None = None,
|
refresh_token: str | None = None,
|
||||||
|
ip_address: str | None = None,
|
||||||
) -> CustomUser:
|
) -> CustomUser:
|
||||||
"""
|
"""
|
||||||
Soft-delete the requesting user and hide their conversations.
|
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)
|
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)
|
_blacklist_refresh_token(refresh_token)
|
||||||
blacklisted = _blacklist_outstanding_tokens(user)
|
blacklisted = _blacklist_outstanding_tokens(user)
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|||||||
@@ -556,6 +556,11 @@ class CustomUserSelfDeleteTestCase(APITestCase):
|
|||||||
self.user.refresh_from_db()
|
self.user.refresh_from_db()
|
||||||
self.assertTrue(self.user.deleted)
|
self.assertTrue(self.user.deleted)
|
||||||
self.assertFalse(self.user.is_active)
|
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(
|
self.assertTrue(
|
||||||
CustomUser.objects.filter(pk=self.user.pk, deleted=True).exists()
|
CustomUser.objects.filter(pk=self.user.pk, deleted=True).exists()
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -330,7 +330,11 @@ class CustomUserSelfDeleteView(APIView):
|
|||||||
|
|
||||||
refresh_token = request.data.get("refresh_token")
|
refresh_token = request.data.get("refresh_token")
|
||||||
try:
|
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:
|
except AccountDeletionError as exc:
|
||||||
return Response(
|
return Response(
|
||||||
{"detail": exc.detail, "code": exc.code},
|
{"detail": exc.detail, "code": exc.code},
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
@@ -241,11 +305,20 @@ def assign_plan_from_stripe(
|
|||||||
) -> UserSubscription:
|
) -> UserSubscription:
|
||||||
"""Assign a catalog plan from a Stripe Checkout / subscription event."""
|
"""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()
|
slug = (plan_slug or "").strip().lower()
|
||||||
plan = get_plan(slug) if slug else None
|
plan = get_plan(slug) if slug else None
|
||||||
if plan is None and keep_existing_plan_if_unknown:
|
if plan is None and keep_existing_plan_if_unknown and existing and existing.plan_id:
|
||||||
existing = UserSubscription.objects.filter(user=user).select_related("plan").first()
|
|
||||||
if existing and existing.plan_id:
|
|
||||||
plan = existing.plan
|
plan = existing.plan
|
||||||
if plan is None:
|
if plan is None:
|
||||||
if slug:
|
if slug:
|
||||||
@@ -257,12 +330,15 @@ def assign_plan_from_stripe(
|
|||||||
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")
|
||||||
|
|
||||||
|
# Single auth-event log after plan + cancel fields are applied.
|
||||||
sub = assign_plan(
|
sub = assign_plan(
|
||||||
user,
|
user,
|
||||||
plan=plan,
|
plan=plan,
|
||||||
source=UserSubscription.Source.STRIPE,
|
source=UserSubscription.Source.STRIPE,
|
||||||
status=status,
|
status=status,
|
||||||
stripe_subscription_id=stripe_subscription_id or "",
|
stripe_subscription_id=stripe_subscription_id or "",
|
||||||
|
log_auth_event=False,
|
||||||
)
|
)
|
||||||
update_fields: list[str] = []
|
update_fields: list[str] = []
|
||||||
if cancel_at_period_end is not None:
|
if cancel_at_period_end is not None:
|
||||||
@@ -273,6 +349,47 @@ def assign_plan_from_stripe(
|
|||||||
update_fields.append("current_period_end")
|
update_fields.append("current_period_end")
|
||||||
if update_fields:
|
if update_fields:
|
||||||
sub.save(update_fields=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
|
return sub
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from finance.models import Invoice, Payment, UserSubscription
|
|||||||
from finance.services.plans import (
|
from finance.services.plans import (
|
||||||
assign_plan_from_stripe,
|
assign_plan_from_stripe,
|
||||||
get_or_create_user_subscription,
|
get_or_create_user_subscription,
|
||||||
|
log_subscription_auth_event,
|
||||||
resolve_plan_from_stripe_price,
|
resolve_plan_from_stripe_price,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -421,6 +422,7 @@ def handle_customer_subscription_deleted(subscription: dict[str, Any]):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
sub = get_or_create_user_subscription(user)
|
sub = get_or_create_user_subscription(user)
|
||||||
|
prev_status = sub.status
|
||||||
sub.status = UserSubscription.Status.CANCELED
|
sub.status = UserSubscription.Status.CANCELED
|
||||||
sub.cancel_at_period_end = False
|
sub.cancel_at_period_end = False
|
||||||
sub.current_period_end = _ts_to_dt(subscription.get("current_period_end"))
|
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:
|
if sub.source == UserSubscription.Source.NONE:
|
||||||
sub.source = UserSubscription.Source.STRIPE
|
sub.source = UserSubscription.Source.STRIPE
|
||||||
sub.save()
|
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
|
return sub
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ 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 chat_backend.models import UserAuthEvent
|
||||||
from finance.models import Invoice, Payment, UserSubscription
|
from finance.models import Invoice, Payment, UserSubscription
|
||||||
from finance.services.plans import assign_plan_from_stripe, seed_subscription_plans
|
from finance.services.plans import assign_plan_from_stripe, seed_subscription_plans
|
||||||
from finance.services.webhooks import (
|
from finance.services.webhooks import (
|
||||||
@@ -123,10 +124,15 @@ class WebhookHandlerUnitTestCase(APITestCase):
|
|||||||
"currency": "usd",
|
"currency": "usd",
|
||||||
}
|
}
|
||||||
handle_checkout_session_completed(session)
|
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.plan.slug, "founders")
|
||||||
self.assertEqual(sub.source, UserSubscription.Source.STRIPE)
|
self.assertEqual(sub.source, UserSubscription.Source.STRIPE)
|
||||||
self.assertEqual(sub.status, UserSubscription.Status.ACTIVE)
|
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):
|
def test_subscription_updated_sets_cancel_at_period_end(self):
|
||||||
seed_subscription_plans(update_existing=False)
|
seed_subscription_plans(update_existing=False)
|
||||||
@@ -149,6 +155,11 @@ class WebhookHandlerUnitTestCase(APITestCase):
|
|||||||
self.assertTrue(sub.cancel_at_period_end)
|
self.assertTrue(sub.cancel_at_period_end)
|
||||||
self.assertEqual(sub.status, UserSubscription.Status.ACTIVE)
|
self.assertEqual(sub.status, UserSubscription.Status.ACTIVE)
|
||||||
self.assertIsNotNone(sub.current_period_end)
|
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):
|
def test_subscription_deleted_marks_canceled(self):
|
||||||
seed_subscription_plans(update_existing=False)
|
seed_subscription_plans(update_existing=False)
|
||||||
@@ -169,6 +180,11 @@ class WebhookHandlerUnitTestCase(APITestCase):
|
|||||||
sub = UserSubscription.objects.get(user=self.user)
|
sub = UserSubscription.objects.get(user=self.user)
|
||||||
self.assertEqual(sub.status, UserSubscription.Status.CANCELED)
|
self.assertEqual(sub.status, UserSubscription.Status.CANCELED)
|
||||||
self.assertFalse(sub.cancel_at_period_end)
|
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):
|
||||||
|
|||||||
Reference in New Issue
Block a user