## Summary - Closes [#34](#34) - Companion for [chat_web_app#75](ai_ml_operations/chat_web_app#75) (portal cancel/change local sync) - Soft-delete `DELETE /api/user/` for the authenticated user only: `deleted=True`, `is_active=False`, hide conversations, blacklist outstanding refresh tokens; staff self-delete rejected - Stripe `customer.subscription.updated` / `deleted` webhooks sync plan status, `cancel_at_period_end`, and `current_period_end`; checkout assigns plan from `metadata.plan_slug` - **UserAuthEvent audit**: `account_deleted`, `subscription_started` (first active plan), `subscription_updated` (plan/status/cancel changes) — visible on user admin - Document FE contract in README (endpoint, response, post-delete logout) ## Test plan - [ ] `uv run python manage.py test chat_backend.tests.test_views_users.CustomUserSelfDeleteTestCase finance.tests` - [ ] Authenticated `DELETE /api/user/` soft-deletes self, hides conversations, blocks re-login, writes `account_deleted` auth event - [ ] Checkout / Backer assign writes `subscription_started`; portal cancel/change writes `subscription_updated` - [ ] Anonymous / staff self-delete rejected; body cannot target another user - [ ] After portal cancel, webhook sets `cancel_at_period_end` / `canceled` on `GET /finance/subscription/`Reviewed-on: #39
This commit was merged in pull request #39.
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user