Add account self-delete and subscription lifecycle sync (#34)
CI / test (pull_request) Successful in 10s
Unit Tests / test (pull_request) Successful in 9s

Soft-delete DELETE /api/user/ for authenticated users (hide conversations,
blacklist tokens, block staff self-delete). Sync Stripe portal cancel/change
via subscription.updated/deleted webhooks and expose cancel_at_period_end for
Account UI (chat_web_app#75 companion).
This commit is contained in:
2026-08-01 14:15:09 -05:00
parent cc45ae5808
commit b3203f755d
12 changed files with 542 additions and 9 deletions
+25
View File
@@ -180,6 +180,31 @@ 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 |
| 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.
## Security note ## Security note
Secrets previously hardcoded in `settings.py` (email password, captcha, Django Secrets previously hardcoded in `settings.py` (email password, captcha, Django
+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,84 @@
"""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
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,
) -> 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)
_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
+100 -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,97 @@ 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)
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(),
+38
View File
@@ -305,6 +305,44 @@ 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)
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,
+52 -3
View File
@@ -221,17 +221,66 @@ 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)
plan = get_plan(SubscriptionPlan.Slug.FOUNDERS) 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:
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)
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( 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 "",
) )
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)
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:
+115 -4
View File
@@ -10,13 +10,67 @@ 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,
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 +267,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 +331,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 +382,57 @@ 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)
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()
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 +444,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
+64 -1
View File
@@ -8,10 +8,13 @@ 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 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 +110,66 @@ 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 = self.user.subscription
self.assertEqual(sub.plan.slug, "founders")
self.assertEqual(sub.source, UserSubscription.Source.STRIPE)
self.assertEqual(sub.status, UserSubscription.Status.ACTIVE)
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)
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)
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)