diff --git a/README.md b/README.md index 9cba6c0..f644d9e 100644 --- a/README.md +++ b/README.md @@ -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 **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": "" }` | +| 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 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/serializers.py b/llm_be/chat_backend/serializers.py index 216cb7b..66490a6 100644 --- a/llm_be/chat_backend/serializers.py +++ b/llm_be/chat_backend/serializers.py @@ -26,6 +26,17 @@ class MyTokenObtainPairSerializer(TokenObtainPairSerializer): 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 Meta: @@ -70,12 +81,18 @@ class CustomUserSerializer(serializers.ModelSerializer): "status": UserSubscription.Status.NONE, "source": UserSubscription.Source.NONE, "needs_checkout": True, + "cancel_at_period_end": False, + "current_period_end": None, } return { "plan": plan_to_dict(sub.plan) if sub.plan_id else None, "status": sub.status, "source": sub.source, "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 + ), } diff --git a/llm_be/chat_backend/services/account_deletion.py b/llm_be/chat_backend/services/account_deletion.py new file mode 100644 index 0000000..148aeaa --- /dev/null +++ b/llm_be/chat_backend/services/account_deletion.py @@ -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 diff --git a/llm_be/chat_backend/tests/test_views_users.py b/llm_be/chat_backend/tests/test_views_users.py index fc41425..3748610 100644 --- a/llm_be/chat_backend/tests/test_views_users.py +++ b/llm_be/chat_backend/tests/test_views_users.py @@ -7,13 +7,18 @@ from rest_framework_simplejwt.tokens import RefreshToken from chat_backend.models import ( Announcement, + Conversation, CustomUser, Feedback, OutboundEmail, 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): @@ -520,3 +525,102 @@ class CustomUserGetTestCase(APITestCase): self.assertEqual(response.data["email"], user.email) self.assertEqual(response.data["company"]["name"], "Globex") 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) diff --git a/llm_be/chat_backend/urls.py b/llm_be/chat_backend/urls.py index 1ad339e..e9244ba 100644 --- a/llm_be/chat_backend/urls.py +++ b/llm_be/chat_backend/urls.py @@ -5,6 +5,7 @@ from .views import ( CustomObtainTokenView, CustomUserCreate, CustomUserInvite, + CustomUserSelfDeleteView, LogoutAndBlacklistRefreshTokenForUserView, CustomUserGet, PublicSettingsView, @@ -57,6 +58,7 @@ urlpatterns = [ name="blacklist", ), path("user/get/", CustomUserGet.as_view(), name="get_user"), + path("user/", CustomUserSelfDeleteView.as_view(), name="delete_user"), path( "user/acknowledge_tos/", AcknowledgeTermsOfService.as_view(), diff --git a/llm_be/chat_backend/views.py b/llm_be/chat_backend/views.py index 8d022f0..1c23dfb 100644 --- a/llm_be/chat_backend/views.py +++ b/llm_be/chat_backend/views.py @@ -305,6 +305,48 @@ class CustomUserGet(APIView): 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": ""}`` 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): http_method_names = ["post", "get"] diff --git a/llm_be/finance/migrations/0003_subscription_cancel_period_fields.py b/llm_be/finance/migrations/0003_subscription_cancel_period_fields.py new file mode 100644 index 0000000..1defa88 --- /dev/null +++ b/llm_be/finance/migrations/0003_subscription_cancel_period_fields.py @@ -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, + ), + ), + ] diff --git a/llm_be/finance/models.py b/llm_be/finance/models.py index 2367260..7084e87 100644 --- a/llm_be/finance/models.py +++ b/llm_be/finance/models.py @@ -149,6 +149,15 @@ class UserSubscription(TimeInfoBase): default="", 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( null=True, blank=True, diff --git a/llm_be/finance/services/plans.py b/llm_be/finance/services/plans.py index 21bf833..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 @@ -221,17 +285,119 @@ def assign_founders_from_stripe( *, stripe_subscription_id: str = "", ) -> 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) - plan = get_plan(SubscriptionPlan.Slug.FOUNDERS) + 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) if plan is None: raise RuntimeError("Founders plan missing from catalog") - return assign_plan( + + # Single auth-event log after plan + cancel fields are applied. + sub = assign_plan( user, plan=plan, source=UserSubscription.Source.STRIPE, - status=UserSubscription.Status.ACTIVE, + 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: + 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: diff --git a/llm_be/finance/services/webhooks.py b/llm_be/finance/services/webhooks.py index 7bec904..90be95f 100644 --- a/llm_be/finance/services/webhooks.py +++ b/llm_be/finance/services/webhooks.py @@ -10,13 +10,68 @@ from django.contrib.auth import get_user_model from django.db import transaction from django.utils import timezone -from finance.models import Invoice, Payment -from finance.services.plans import assign_founders_from_stripe +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, +) logger = logging.getLogger(__name__) 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): if not value: return None @@ -213,8 +268,9 @@ def handle_checkout_session_completed(session: dict[str, Any]) -> Invoice | None paid_at=timezone.now(), ) if session.get("payment_status") == "paid" or session.get("subscription"): - assign_founders_from_stripe( + assign_plan_from_stripe( user, + plan_slug=metadata.get("plan_slug"), stripe_subscription_id=session.get("subscription") or "", ) 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, paid_at=paid_at, ) - assign_founders_from_stripe( + assign_plan_from_stripe( user, + plan_slug=metadata.get("plan_slug"), stripe_subscription_id=stripe_invoice.get("subscription") or "", ) return invoice @@ -326,6 +383,68 @@ def handle_invoice_payment_failed(stripe_invoice: dict[str, Any]) -> Invoice | N 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]): """Route a verified Stripe event to the appropriate handler.""" event_type = event.get("type") @@ -337,6 +456,10 @@ def dispatch_stripe_event(event: dict[str, Any]): return handle_invoice_paid(data_object) if event_type == "invoice.payment_failed": 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) return None diff --git a/llm_be/finance/tests/test_webhooks.py b/llm_be/finance/tests/test_webhooks.py index 1c5cdab..9acc3bb 100644 --- a/llm_be/finance/tests/test_webhooks.py +++ b/llm_be/finance/tests/test_webhooks.py @@ -8,10 +8,14 @@ from rest_framework import status from rest_framework.test import APITestCase 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 ( dispatch_stripe_event, handle_checkout_session_completed, + handle_customer_subscription_deleted, + handle_customer_subscription_updated, handle_invoice_paid, handle_invoice_payment_failed, ) @@ -107,6 +111,81 @@ class WebhookHandlerUnitTestCase(APITestCase): ) 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): def setUp(self): diff --git a/llm_be/finance/views.py b/llm_be/finance/views.py index 5cb6c36..c6ae724 100644 --- a/llm_be/finance/views.py +++ b/llm_be/finance/views.py @@ -199,6 +199,12 @@ class SubscriptionMeView(APIView): "stripe_subscription_id": ( 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(), } return Response(payload)