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
+52 -3
View File
@@ -221,17 +221,66 @@ 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)
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:
raise RuntimeError("Founders plan missing from catalog")
return assign_plan(
sub = assign_plan(
user,
plan=plan,
source=UserSubscription.Source.STRIPE,
status=UserSubscription.Status.ACTIVE,
status=status,
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:
+115 -4
View File
@@ -10,13 +10,67 @@ 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,
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 +267,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 +331,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 +382,57 @@ 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)
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]):
"""Route a verified Stripe event to the appropriate handler."""
event_type = event.get("type")
@@ -337,6 +444,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