Add monetization app with RevenueCat webhooks alongside Stripe.
CI / test (pull_request) Successful in 11s
Unit Tests / test (pull_request) Successful in 11s

Rename finance → monetization (keep finance_* tables via app label), add
RevenueCat webhook + ledger upserts so store IAP syncs subscriptions and
billing history like Stripe. Companion to chat_web_app#100 / #68.
This commit is contained in:
2026-08-03 14:57:29 -05:00
parent bf3fffa343
commit 07c03ce65d
44 changed files with 965 additions and 102 deletions
+576
View File
@@ -0,0 +1,576 @@
"""Subscription plan catalog helpers, seeding, and user assignment."""
from __future__ import annotations
import logging
from typing import Any
from django.conf import settings
from django.db import transaction
from django.utils import timezone
from chat_backend.models import UserAuthEvent
from monetization.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]] = [
{
"slug": SubscriptionPlan.Slug.FOUNDERS,
"name": "Founders",
"description": (
"Unlimited product access for early supporters: text plus all future "
"capabilities as they ship. $10/mo."
),
"price_cents": 1000,
"is_public": True,
"is_selectable": True,
"allows_text_generation": True,
"allows_image_generation": True,
"allows_rag": True,
"allows_all_future_features": True,
"prompt_quota_per_window": 300,
"prompt_window_hours": 6,
"monthly_token_quota": None,
"sort_order": 10,
},
{
"slug": SubscriptionPlan.Slug.STANDARD,
"name": "Standard",
"description": (
"Secure conversational chat and coding assistance for developers "
"and privacy-conscious individuals."
),
"price_cents": 1500,
"is_public": False,
"is_selectable": False,
"allows_text_generation": True,
"allows_image_generation": False,
"allows_rag": False,
"allows_all_future_features": False,
"prompt_quota_per_window": 100,
"prompt_window_hours": 6,
"monthly_token_quota": 1_000_000,
"sort_order": 20,
},
{
"slug": SubscriptionPlan.Slug.PRO,
"name": "Pro / Creator",
"description": (
"Higher message caps and multi-modal workflows for heavy users, "
"including image generation and Drive/RAG sync when available."
),
"price_cents": 4000,
"is_public": False,
"is_selectable": False,
"allows_text_generation": True,
"allows_image_generation": True,
"allows_rag": True,
"allows_all_future_features": False,
"prompt_quota_per_window": 200,
"prompt_window_hours": 6,
"monthly_token_quota": 3_000_000,
"sort_order": 30,
},
{
"slug": SubscriptionPlan.Slug.BUSINESS,
"name": "Business Team",
"description": (
"Team seats, centralized auth, priority support, company Drive/RAG "
"sync, and absolute data privacy for local companies handling "
"sensitive data."
),
"price_cents": 9900,
"is_public": False,
"is_selectable": False,
"allows_text_generation": True,
"allows_image_generation": True,
"allows_rag": True,
"allows_all_future_features": False,
"prompt_quota_per_window": 300,
"prompt_window_hours": 6,
"monthly_token_quota": 5_000_000,
"sort_order": 40,
},
{
"slug": SubscriptionPlan.Slug.BACKER,
"name": "Backer",
"description": (
"Complimentary Founders-level access for pre-approved emails. "
"Not shown at checkout."
),
"price_cents": 0,
"is_public": False,
"is_selectable": False,
"allows_text_generation": True,
"allows_image_generation": True,
"allows_rag": True,
"allows_all_future_features": True,
"prompt_quota_per_window": 300,
"prompt_window_hours": 6,
"monthly_token_quota": None,
"sort_order": 5,
},
]
def seed_subscription_plans(*, update_existing: bool = True) -> list[SubscriptionPlan]:
"""Idempotently create/update the canonical plan catalog."""
plans: list[SubscriptionPlan] = []
for row in PLAN_SEED:
slug = row["slug"]
defaults = {k: v for k, v in row.items() if k != "slug"}
plan, created = SubscriptionPlan.objects.get_or_create(
slug=slug,
defaults=defaults,
)
if not created and update_existing:
for key, value in defaults.items():
setattr(plan, key, value)
plan.save()
plans.append(plan)
return plans
def get_plan(slug: str) -> SubscriptionPlan | None:
return SubscriptionPlan.objects.filter(slug=slug).first()
def get_or_create_user_subscription(user) -> UserSubscription:
sub, _ = UserSubscription.objects.get_or_create(user=user)
return sub
def assign_plan(
user,
*,
plan: SubscriptionPlan,
source: str,
status: str = UserSubscription.Status.ACTIVE,
stripe_subscription_id: str = "",
revenuecat_original_transaction_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 ""
prev_rc_txn = sub.revenuecat_original_transaction_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
if revenuecat_original_transaction_id:
sub.revenuecat_original_transaction_id = revenuecat_original_transaction_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 "")
)
or (
bool(revenuecat_original_transaction_id)
and prev_rc_txn != (sub.revenuecat_original_transaction_id or "")
)
)
extra = ""
if stripe_subscription_id:
extra += f" stripe_subscription_id={stripe_subscription_id}"
if revenuecat_original_transaction_id:
extra += (
f" revenuecat_original_transaction_id="
f"{revenuecat_original_transaction_id}"
)
if became_active and not had_active:
log_subscription_auth_event(
user,
started=True,
detail=f"plan={plan.slug} source={source} status={status}{extra}",
)
elif changed:
log_subscription_auth_event(
user,
started=False,
detail=f"plan={plan.slug} source={source} status={status}{extra}",
)
return sub
def user_has_active_plan(user) -> bool:
try:
sub = user.subscription
except UserSubscription.DoesNotExist:
return False
return sub.is_active
def needs_checkout(user) -> bool:
"""True when the user must complete paid Checkout to use the product."""
try:
sub = user.subscription
except UserSubscription.DoesNotExist:
return True
if not sub.is_active:
return True
# Complimentary / already-paid tiers skip Checkout.
if sub.source in (
UserSubscription.Source.BACKER,
UserSubscription.Source.ADMIN,
UserSubscription.Source.STRIPE,
UserSubscription.Source.REVENUECAT,
):
return False
return True
@transaction.atomic
def try_redeem_backer_email(user) -> UserSubscription | None:
"""
If the user's email is on the Backer whitelist and unused, assign Backer plan.
Returns the UserSubscription when redeemed, else None.
"""
email = (getattr(user, "email", "") or "").strip().lower()
if not email:
return None
entry = (
BackerEmail.objects.select_for_update()
.filter(email__iexact=email, redeemed_at__isnull=True)
.first()
)
if entry is None:
return None
seed_subscription_plans(update_existing=False)
plan = get_plan(SubscriptionPlan.Slug.BACKER)
if plan is None:
logger.error("Backer plan missing from catalog; cannot redeem %s", email)
return None
sub = assign_plan(
user,
plan=plan,
source=UserSubscription.Source.BACKER,
status=UserSubscription.Status.ACTIVE,
)
entry.redeemed_at = timezone.now()
entry.redeemed_user = user
entry.save(update_fields=["redeemed_at", "redeemed_user", "last_modified"])
logger.info("Redeemed Backer email %s for user %s", email, user.pk)
return sub
def assign_founders_from_stripe(
user,
*,
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)
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")
# Single auth-event log after plan + cancel fields are applied.
sub = assign_plan(
user,
plan=plan,
source=UserSubscription.Source.STRIPE,
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 resolve_plan_from_revenuecat_product(
product_id: str | None,
) -> SubscriptionPlan | None:
"""Map a store/RevenueCat product id to a local SubscriptionPlan."""
if not product_id:
return None
seed_subscription_plans(update_existing=False)
plan = SubscriptionPlan.objects.filter(
revenuecat_product_id=product_id
).first()
if plan:
return plan
# Optional env map: {"com.app.pro.monthly": "pro", ...}
mapping = getattr(settings, "REVENUECAT_PRODUCT_PLAN_MAP", None) or {}
if isinstance(mapping, dict):
slug = mapping.get(product_id)
if slug:
plan = get_plan(str(slug))
if plan:
return plan
# Heuristic: product id contains a known plan slug.
lowered = product_id.lower()
for slug in (
SubscriptionPlan.Slug.FOUNDERS,
SubscriptionPlan.Slug.BUSINESS,
SubscriptionPlan.Slug.STANDARD,
SubscriptionPlan.Slug.PRO,
):
if slug in lowered:
plan = get_plan(slug)
if plan:
return plan
return None
def assign_plan_from_revenuecat(
user,
*,
plan_slug: str | None = None,
product_id: str | None = None,
revenuecat_original_transaction_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 RevenueCat store purchase event."""
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()
plan = get_plan(slug) if slug else None
if plan is None and product_id:
plan = resolve_plan_from_revenuecat_product(product_id)
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 or product_id:
logger.warning(
"Unknown RC product/plan product_id=%s plan_slug=%s; "
"falling back to Founders for user=%s",
product_id,
slug,
getattr(user, "pk", None),
)
plan = get_plan(SubscriptionPlan.Slug.FOUNDERS)
if plan is None:
raise RuntimeError("Founders plan missing from catalog")
sub = assign_plan(
user,
plan=plan,
source=UserSubscription.Source.REVENUECAT,
status=status,
revenuecat_original_transaction_id=revenuecat_original_transaction_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(revenuecat_original_transaction_id)
and (existing.revenuecat_original_transaction_id or "")
!= (sub.revenuecat_original_transaction_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 plan_to_dict(plan: SubscriptionPlan | None) -> dict[str, Any] | None:
if plan is None:
return None
return {
"slug": plan.slug,
"name": plan.name,
"description": plan.description,
"price_cents": plan.price_cents,
"currency": plan.currency,
"interval": plan.interval,
"is_public": plan.is_public,
"is_selectable": plan.is_selectable,
"features": {
"text_generation": plan.allows_feature("text_generation"),
"image_generation": plan.allows_feature("image_generation"),
"rag": plan.allows_feature("rag"),
"all_future_features": plan.allows_all_future_features,
},
"prompt_quota_per_window": plan.prompt_quota_per_window,
"prompt_window_hours": plan.prompt_window_hours,
"monthly_token_quota": plan.monthly_token_quota,
"sort_order": plan.sort_order,
}
+254
View File
@@ -0,0 +1,254 @@
"""Prompt-window and token-period quota checks (shared by chat + finance APIs)."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import timedelta
from typing import Any
from django.conf import settings
from django.db.models import Count, Q, Sum
from django.utils import timezone
from chat_backend.models import PromptMetric
from monetization.models import UserSubscription
from monetization.services.plans import get_or_create_user_subscription, seed_subscription_plans
class QuotaExceeded(Exception):
"""Raised when a generation turn is blocked by quota."""
def __init__(self, code: str, message: str, *, details: dict | None = None):
super().__init__(message)
self.code = code
self.message = message
self.details = details or {}
class FeatureNotAllowed(Exception):
"""Raised when the user's plan cannot use a feature."""
def __init__(self, code: str, message: str, *, details: dict | None = None):
super().__init__(message)
self.code = code
self.message = message
self.details = details or {}
@dataclass
class UsageSnapshot:
prompts_in_window: int
prompt_quota: int | None
prompts_remaining: int | None
window_hours: int
tokens_in_period: int | None
tokens_out_period: int | None
tokens_total_period: int | None
turns_missing_token_usage: int
monthly_token_quota: int | None
tokens_remaining: int | None
period_start: Any
period_end: Any
def to_dict(self) -> dict[str, Any]:
return {
"prompts_in_window": self.prompts_in_window,
"prompt_quota": self.prompt_quota,
"prompts_remaining": self.prompts_remaining,
"window_hours": self.window_hours,
"tokens_in_period": self.tokens_in_period,
"tokens_out_period": self.tokens_out_period,
"tokens_total_period": self.tokens_total_period,
"turns_missing_token_usage": self.turns_missing_token_usage,
"monthly_token_quota": self.monthly_token_quota,
"tokens_remaining": self.tokens_remaining,
"period_start": self.period_start.isoformat() if self.period_start else None,
"period_end": self.period_end.isoformat() if self.period_end else None,
}
def _user_conversation_ids(user) -> list[int]:
from chat_backend.models import Conversation
return list(
Conversation.objects.filter(user=user, deleted=False).values_list("id", flat=True)
)
def _billing_period_bounds():
"""Calendar-month UTC window for token-period aggregation (#17)."""
now = timezone.now()
start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
if start.month == 12:
end = start.replace(year=start.year + 1, month=1)
else:
end = start.replace(month=start.month + 1)
return start, end
def _sum_tokens(qs) -> tuple[int | None, int | None]:
"""
Sum tokens_in / tokens_out.
Returns (None, None) when *no* rows reported usage — never fabricate 0.
When some rows reported usage, sum only those (nulls ignored by Sum).
"""
agg = qs.aggregate(
tin=Sum("tokens_in"),
tout=Sum("tokens_out"),
with_in=Count("id", filter=Q(tokens_in__isnull=False)),
with_out=Count("id", filter=Q(tokens_out__isnull=False)),
)
tokens_in = agg["tin"] if agg["with_in"] else None
tokens_out = agg["tout"] if agg["with_out"] else None
return tokens_in, tokens_out
def get_usage_snapshot(user) -> UsageSnapshot:
seed_subscription_plans(update_existing=False)
sub = (
UserSubscription.objects.select_related("plan")
.filter(user_id=user.pk)
.first()
)
if sub is None:
sub = get_or_create_user_subscription(user)
plan = sub.plan if sub.is_active else None
window_hours = plan.prompt_window_hours if plan else 6
prompt_quota = plan.prompt_quota_per_window if plan else None
monthly_token_quota = sub.effective_monthly_token_quota() if sub.is_active else None
conversation_ids = _user_conversation_ids(user)
now = timezone.now()
window_start = now - timedelta(hours=window_hours)
period_start, period_end = _billing_period_bounds()
base = PromptMetric.objects.filter(conversation_id__in=conversation_ids)
prompts_in_window = base.filter(created__gte=window_start).count()
period_qs = base.filter(created__gte=period_start, created__lt=period_end)
tokens_in, tokens_out = _sum_tokens(period_qs)
missing = period_qs.filter(
Q(tokens_in__isnull=True) | Q(tokens_out__isnull=True)
).count()
if tokens_in is None and tokens_out is None:
tokens_total = None
else:
tokens_total = (tokens_in or 0) + (tokens_out or 0)
prompts_remaining = None
if prompt_quota is not None:
prompts_remaining = max(prompt_quota - prompts_in_window, 0)
tokens_remaining = None
if monthly_token_quota is not None and tokens_total is not None:
tokens_remaining = max(monthly_token_quota - tokens_total, 0)
elif monthly_token_quota is not None and tokens_total is None:
# No provider usage yet — do not treat as 0 consumed.
tokens_remaining = monthly_token_quota
return UsageSnapshot(
prompts_in_window=prompts_in_window,
prompt_quota=prompt_quota,
prompts_remaining=prompts_remaining,
window_hours=window_hours,
tokens_in_period=tokens_in,
tokens_out_period=tokens_out,
tokens_total_period=tokens_total,
turns_missing_token_usage=missing,
monthly_token_quota=monthly_token_quota,
tokens_remaining=tokens_remaining,
period_start=period_start,
period_end=period_end,
)
def assert_feature_allowed(user, feature: str) -> None:
if not getattr(settings, "ENFORCE_SUBSCRIPTION_GATES", True):
return
seed_subscription_plans(update_existing=False)
sub = (
UserSubscription.objects.select_related("plan")
.filter(user_id=user.pk)
.first()
)
if sub is None or not sub.is_active or sub.plan is None:
raise FeatureNotAllowed(
"subscription_required",
"An active subscription is required to use this feature.",
details={"feature": feature},
)
if not sub.plan.allows_feature(feature):
raise FeatureNotAllowed(
"feature_not_allowed",
f"Your plan ({sub.plan.name}) does not include {feature.replace('_', ' ')}.",
details={
"feature": feature,
"plan": sub.plan.slug,
},
)
def assert_within_quotas(user) -> UsageSnapshot:
"""
Enforce prompt-window (#36) and token-period (#17) limits.
Precedence: either limit may block. Missing provider token usage does not
silently under-count toward a token cap — turns with null tokens are tracked
in `turns_missing_token_usage` and token-cap enforcement only uses reported
sums; if quota is set and usage is entirely unknown, we allow the turn but
surface the gap (callers/admin can tighten later).
"""
seed_subscription_plans(update_existing=False)
sub = (
UserSubscription.objects.select_related("plan")
.filter(user_id=user.pk)
.first()
)
if sub is None or not sub.is_active or sub.plan is None:
raise QuotaExceeded(
"subscription_required",
"An active subscription is required before sending prompts.",
)
usage = get_usage_snapshot(user)
if usage.prompt_quota is not None and usage.prompts_in_window >= usage.prompt_quota:
raise QuotaExceeded(
"prompt_quota_exceeded",
(
f"Prompt limit reached ({usage.prompt_quota} per "
f"{usage.window_hours} hours). Try again later."
),
details=usage.to_dict(),
)
if (
usage.monthly_token_quota is not None
and usage.tokens_total_period is not None
and usage.tokens_total_period >= usage.monthly_token_quota
):
raise QuotaExceeded(
"token_quota_exceeded",
(
f"Monthly token limit reached ({usage.monthly_token_quota}). "
"Upgrade or wait for the next billing period."
),
details=usage.to_dict(),
)
return usage
def check_generation_allowed(user, *, feature: str = "text_generation") -> UsageSnapshot:
"""Combined feature + quota gate for a chat turn."""
if not getattr(settings, "ENFORCE_SUBSCRIPTION_GATES", True):
return get_usage_snapshot(user)
assert_feature_allowed(user, feature)
return assert_within_quotas(user)
+319
View File
@@ -0,0 +1,319 @@
"""RevenueCat store IAP helpers and webhook dispatch (ledger + entitlements)."""
from __future__ import annotations
import logging
from datetime import datetime, timezone as dt_timezone
from typing import Any
from django.contrib.auth import get_user_model
from django.db import transaction
from django.utils import timezone
from monetization.models import Invoice, Payment, UserSubscription
from monetization.services.plans import (
assign_plan_from_revenuecat,
get_or_create_user_subscription,
log_subscription_auth_event,
resolve_plan_from_revenuecat_product,
)
logger = logging.getLogger(__name__)
User = get_user_model()
# Events that grant or refresh paid access.
_ACTIVE_EVENT_TYPES = frozenset(
{
"INITIAL_PURCHASE",
"RENEWAL",
"UNCANCELLATION",
"NON_RENEWING_PURCHASE",
"PRODUCT_CHANGE",
"SUBSCRIPTION_EXTENDED",
}
)
# Still entitled until period end (cancel scheduled).
_CANCEL_AT_PERIOD_END_TYPES = frozenset({"CANCELLATION"})
# Access ended / payment problems.
_EXPIRED_EVENT_TYPES = frozenset({"EXPIRATION"})
_BILLING_ISSUE_TYPES = frozenset({"BILLING_ISSUE"})
class RevenueCatWebhookAuthError(ValueError):
"""Invalid or missing RevenueCat webhook Authorization header."""
def verify_revenuecat_authorization(
*,
authorization_header: str | None,
expected_secret: str,
) -> None:
"""Validate ``Authorization: Bearer <secret>`` (or raw secret)."""
if not expected_secret:
raise RevenueCatWebhookAuthError("REVENUECAT_WEBHOOK_SECRET is not configured")
header = (authorization_header or "").strip()
if not header:
raise RevenueCatWebhookAuthError("Missing Authorization header")
token = header
if header.lower().startswith("bearer "):
token = header[7:].strip()
if token != expected_secret:
raise RevenueCatWebhookAuthError("Invalid Authorization token")
def _ms_to_dt(value: int | float | None):
if value is None:
return None
try:
ms = int(value)
except (TypeError, ValueError):
return None
if ms <= 0:
return None
return datetime.fromtimestamp(ms / 1000.0, tz=dt_timezone.utc)
def _price_to_cents(event: dict[str, Any]) -> int:
"""RevenueCat ``price`` is major units in USD; prefer purchased currency."""
raw = event.get("price_in_purchased_currency")
if raw is None:
raw = event.get("price")
try:
return max(0, int(round(float(raw or 0) * 100)))
except (TypeError, ValueError):
return 0
def _resolve_user_from_app_user_id(app_user_id: str | None):
if not app_user_id:
return None
# Prefer numeric PK (what the Capacitor client should send via Purchases.logIn).
try:
return User.objects.get(pk=int(str(app_user_id).strip()))
except (User.DoesNotExist, TypeError, ValueError):
pass
# Fallback: email as app user id.
user = User.objects.filter(email__iexact=str(app_user_id).strip()).first()
if user:
return user
logger.warning("RevenueCat webhook: app_user_id=%s not found", app_user_id)
return None
def _store_label(store: str | None) -> str:
return (store or "").strip().upper()
def _description_for_event(event: dict[str, Any]) -> str:
store = _store_label(event.get("store"))
product = event.get("product_id") or "subscription"
etype = event.get("type") or "purchase"
parts = [f"Store IAP ({store})" if store else "Store IAP", product, etype]
return "".join(p for p in parts if p)
@transaction.atomic
def upsert_invoice_from_revenuecat(
*,
user,
event: dict[str, Any],
status: str,
) -> Invoice:
event_id = event.get("id")
if not event_id:
raise ValueError("RevenueCat event missing id")
amount = _price_to_cents(event)
currency = (event.get("currency") or "usd").lower()
period_start = _ms_to_dt(event.get("purchased_at_ms"))
period_end = _ms_to_dt(event.get("expiration_at_ms"))
amount_paid = amount if status == Invoice.Status.PAID else 0
invoice, _created = Invoice.objects.update_or_create(
revenuecat_event_id=event_id,
defaults={
"user": user,
"company": getattr(user, "company", None),
"provider": Invoice.Provider.REVENUECAT,
"status": status,
"currency": currency,
"amount_due": amount,
"amount_paid": amount_paid,
"period_start": period_start,
"period_end": period_end,
"revenuecat_store": _store_label(event.get("store")),
"description": _description_for_event(event),
"hosted_invoice_url": "",
},
)
return invoice
@transaction.atomic
def upsert_payment_from_revenuecat(
*,
user,
invoice: Invoice | None,
event: dict[str, Any],
status: str,
failure_message: str = "",
) -> Payment | None:
txn_id = event.get("transaction_id") or event.get("id")
if not txn_id:
return None
amount = _price_to_cents(event)
currency = (event.get("currency") or "usd").lower()
paid_at = (
_ms_to_dt(event.get("purchased_at_ms"))
if status == Payment.Status.SUCCEEDED
else None
) or (timezone.now() if status == Payment.Status.SUCCEEDED else None)
payment, _created = Payment.objects.update_or_create(
revenuecat_transaction_id=str(txn_id),
defaults={
"user": user,
"company": getattr(user, "company", None),
"invoice": invoice,
"provider": Payment.Provider.REVENUECAT,
"status": status,
"currency": currency,
"amount": amount,
"paid_at": paid_at,
"failure_message": failure_message or "",
},
)
return payment
def handle_revenuecat_event(event: dict[str, Any]):
"""Apply one RevenueCat ``event`` object: subscription + invoice/payment."""
event_type = (event.get("type") or "").upper()
app_user_id = event.get("app_user_id") or event.get("original_app_user_id")
user = _resolve_user_from_app_user_id(app_user_id)
if user is None:
# TRANSFER may use different fields; still log.
logger.error(
"RevenueCat %s: cannot resolve user app_user_id=%s event=%s",
event_type,
app_user_id,
event.get("id"),
)
return None
product_id = event.get("product_id")
original_txn = (
event.get("original_transaction_id")
or event.get("transaction_id")
or ""
)
period_end = _ms_to_dt(event.get("expiration_at_ms"))
plan = resolve_plan_from_revenuecat_product(product_id)
if event_type in _ACTIVE_EVENT_TYPES:
invoice = upsert_invoice_from_revenuecat(
user=user, event=event, status=Invoice.Status.PAID
)
upsert_payment_from_revenuecat(
user=user,
invoice=invoice,
event=event,
status=Payment.Status.SUCCEEDED,
)
return assign_plan_from_revenuecat(
user,
plan_slug=plan.slug if plan else None,
product_id=product_id,
revenuecat_original_transaction_id=str(original_txn),
status=UserSubscription.Status.ACTIVE,
cancel_at_period_end=False,
current_period_end=period_end,
keep_existing_plan_if_unknown=True,
)
if event_type in _CANCEL_AT_PERIOD_END_TYPES:
# User canceled in store; access continues until expiration.
invoice = upsert_invoice_from_revenuecat(
user=user, event=event, status=Invoice.Status.OPEN
)
return assign_plan_from_revenuecat(
user,
plan_slug=plan.slug if plan else None,
product_id=product_id,
revenuecat_original_transaction_id=str(original_txn),
status=UserSubscription.Status.ACTIVE,
cancel_at_period_end=True,
current_period_end=period_end,
keep_existing_plan_if_unknown=True,
)
if event_type in _BILLING_ISSUE_TYPES:
invoice = upsert_invoice_from_revenuecat(
user=user, event=event, status=Invoice.Status.PAYMENT_FAILED
)
upsert_payment_from_revenuecat(
user=user,
invoice=invoice,
event=event,
status=Payment.Status.FAILED,
failure_message="Store billing issue",
)
return assign_plan_from_revenuecat(
user,
plan_slug=plan.slug if plan else None,
product_id=product_id,
revenuecat_original_transaction_id=str(original_txn),
status=UserSubscription.Status.PAST_DUE,
current_period_end=period_end,
keep_existing_plan_if_unknown=True,
)
if event_type in _EXPIRED_EVENT_TYPES:
upsert_invoice_from_revenuecat(
user=user, event=event, status=Invoice.Status.VOID
)
sub = get_or_create_user_subscription(user)
prev_status = sub.status
sub.status = UserSubscription.Status.CANCELED
sub.cancel_at_period_end = False
if period_end:
sub.current_period_end = period_end
if original_txn:
sub.revenuecat_original_transaction_id = str(original_txn)
if sub.source == UserSubscription.Source.NONE:
sub.source = UserSubscription.Source.REVENUECAT
elif sub.source != UserSubscription.Source.REVENUECAT:
# Only expire if this was a store sub; leave Stripe alone.
if sub.source == UserSubscription.Source.STRIPE:
logger.info(
"Ignoring RC EXPIRATION for Stripe-sourced user=%s", user.pk
)
return sub
sub.source = UserSubscription.Source.REVENUECAT
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"revenuecat_original_transaction_id="
f"{sub.revenuecat_original_transaction_id}"
),
)
return sub
logger.info("Ignoring unhandled RevenueCat event type: %s", event_type)
return None
def dispatch_revenuecat_event(payload: dict[str, Any]):
"""Route a verified RevenueCat webhook JSON body."""
event = payload.get("event") if isinstance(payload.get("event"), dict) else payload
if not isinstance(event, dict):
raise ValueError("RevenueCat payload missing event object")
return handle_revenuecat_event(event)
+139
View File
@@ -0,0 +1,139 @@
"""Stripe Checkout, Billing Portal, and webhook dispatch."""
from __future__ import annotations
from typing import Any
import stripe
from django.conf import settings
from monetization.models import Invoice, SubscriptionPlan
from monetization.services.plans import get_plan, seed_subscription_plans
from monetization.services.webhooks import dispatch_stripe_event
__all__ = [
"StripeNotConfiguredError",
"configure_stripe",
"resolve_checkout_plan",
"subscription_line_items",
"create_checkout_session",
"resolve_stripe_customer_id",
"create_billing_portal_session",
"dispatch_stripe_event",
]
class StripeNotConfiguredError(RuntimeError):
"""Raised when Stripe secret key is missing."""
def configure_stripe() -> str:
secret = settings.STRIPE_SECRET_KEY
if not secret:
raise StripeNotConfiguredError(
"STRIPE_SECRET_KEY is not configured. Set it in the environment."
)
stripe.api_key = secret
return secret
def resolve_checkout_plan(plan_slug: str | None = None) -> SubscriptionPlan:
"""Return the plan for Checkout (defaults to public Founders)."""
seed_subscription_plans(update_existing=False)
slug = (plan_slug or SubscriptionPlan.Slug.FOUNDERS).strip().lower()
plan = get_plan(slug)
if plan is None:
raise ValueError(f"Unknown plan: {slug}")
if not plan.is_selectable:
raise ValueError(f"Plan '{plan.slug}' is not available for checkout.")
return plan
def subscription_line_items(plan: SubscriptionPlan) -> list[dict[str, Any]]:
"""Build Checkout line_items from a SubscriptionPlan (or legacy settings)."""
price_id = (plan.stripe_price_id or "").strip() or (
settings.STRIPE_PRICE_ID if plan.slug == SubscriptionPlan.Slug.FOUNDERS else ""
)
if price_id:
return [{"price": price_id, "quantity": 1}]
# Founders without a plan stripe_price_id may still use legacy env amount.
unit_amount = plan.price_cents
product_name = plan.name
currency = plan.currency or settings.SUBSCRIPTION_PRICE_CURRENCY
interval = plan.interval or settings.SUBSCRIPTION_PRICE_INTERVAL
if plan.slug == SubscriptionPlan.Slug.FOUNDERS and not plan.stripe_price_id:
# Keep env overrides working for the live Founders price.
unit_amount = int(
getattr(settings, "SUBSCRIPTION_PRICE_AMOUNT_CENTS", None) or unit_amount
)
product_name = (
getattr(settings, "SUBSCRIPTION_PRODUCT_NAME", None) or product_name
)
return [
{
"price_data": {
"currency": currency,
"unit_amount": unit_amount,
"recurring": {"interval": interval},
"product_data": {"name": product_name},
},
"quantity": 1,
}
]
def create_checkout_session(
*,
user,
success_url: str | None = None,
cancel_url: str | None = None,
plan_slug: str | None = None,
):
"""Create a Stripe Checkout Session for a selectable subscription plan."""
configure_stripe()
plan = resolve_checkout_plan(plan_slug)
metadata = {
"user_id": str(user.pk),
"company_id": str(user.company_id) if user.company_id else "",
"plan_slug": plan.slug,
}
customer_email = getattr(user, "email", None) or None
session = stripe.checkout.Session.create(
mode="subscription",
line_items=subscription_line_items(plan),
success_url=success_url or settings.STRIPE_CHECKOUT_SUCCESS_URL,
cancel_url=cancel_url or settings.STRIPE_CHECKOUT_CANCEL_URL,
customer_email=customer_email,
client_reference_id=str(user.pk),
metadata=metadata,
subscription_data={"metadata": metadata},
)
return session, plan
def resolve_stripe_customer_id(*, user) -> str | None:
"""Return the most recent Stripe customer id stored on the user's invoices."""
return (
Invoice.objects.filter(user=user)
.exclude(stripe_customer_id="")
.order_by("-created")
.values_list("stripe_customer_id", flat=True)
.first()
)
def create_billing_portal_session(
*,
customer_id: str,
return_url: str | None = None,
):
"""Create a Stripe Customer Portal session for plan/payment/cancel management."""
configure_stripe()
return stripe.billing_portal.Session.create(
customer=customer_id,
return_url=return_url or settings.STRIPE_PORTAL_RETURN_URL,
)
+465
View File
@@ -0,0 +1,465 @@
"""Idempotent Stripe webhook handlers that upsert Invoice / Payment rows."""
from __future__ import annotations
import logging
from datetime import datetime, timezone as dt_timezone
from typing import Any
from django.contrib.auth import get_user_model
from django.db import transaction
from django.utils import timezone
from monetization.models import Invoice, Payment, UserSubscription
from monetization.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
return datetime.fromtimestamp(value, tz=dt_timezone.utc)
def _resolve_user(*, user_id: str | None = None, customer_email: str | None = None):
if user_id:
try:
return User.objects.get(pk=int(user_id))
except (User.DoesNotExist, TypeError, ValueError):
logger.warning("Webhook: user_id=%s not found", user_id)
if customer_email:
user = User.objects.filter(email__iexact=customer_email).first()
if user:
return user
logger.warning("Webhook: email=%s not found", customer_email)
return None
def _user_from_metadata(metadata: dict | None, *, email: str | None = None):
metadata = metadata or {}
return _resolve_user(
user_id=metadata.get("user_id") or metadata.get("client_reference_id"),
customer_email=email,
)
@transaction.atomic
def upsert_invoice_from_stripe(
*,
user,
stripe_invoice: dict[str, Any] | None = None,
stripe_checkout_session_id: str | None = None,
stripe_subscription_id: str | None = None,
stripe_customer_id: str | None = None,
status: str,
amount_due: int = 0,
amount_paid: int = 0,
currency: str = "usd",
period_start=None,
period_end=None,
hosted_invoice_url: str = "",
description: str = "",
) -> Invoice:
stripe_invoice_id = None
if stripe_invoice:
stripe_invoice_id = stripe_invoice.get("id")
stripe_subscription_id = (
stripe_subscription_id or stripe_invoice.get("subscription") or None
)
stripe_customer_id = (
stripe_customer_id or stripe_invoice.get("customer") or None
)
amount_due = int(stripe_invoice.get("amount_due") or amount_due or 0)
amount_paid = int(stripe_invoice.get("amount_paid") or amount_paid or 0)
currency = (stripe_invoice.get("currency") or currency or "usd").lower()
period_start = period_start or _ts_to_dt(
(stripe_invoice.get("period_start") or stripe_invoice.get("created"))
)
period_end = period_end or _ts_to_dt(stripe_invoice.get("period_end"))
hosted_invoice_url = (
hosted_invoice_url or stripe_invoice.get("hosted_invoice_url") or ""
)
description = description or stripe_invoice.get("description") or ""
lookup: dict[str, Any] = {}
if stripe_invoice_id:
lookup["stripe_invoice_id"] = stripe_invoice_id
elif stripe_checkout_session_id:
lookup["stripe_checkout_session_id"] = stripe_checkout_session_id
else:
raise ValueError("Need stripe_invoice_id or stripe_checkout_session_id")
defaults = {
"user": user,
"company": getattr(user, "company", None),
"provider": Invoice.Provider.STRIPE,
"status": status,
"currency": currency,
"amount_due": amount_due,
"amount_paid": amount_paid,
"period_start": period_start,
"period_end": period_end,
"stripe_subscription_id": stripe_subscription_id or None,
"stripe_customer_id": stripe_customer_id or "",
"hosted_invoice_url": hosted_invoice_url or "",
"description": description or "",
}
if stripe_invoice_id:
defaults["stripe_invoice_id"] = stripe_invoice_id
if stripe_checkout_session_id:
defaults["stripe_checkout_session_id"] = stripe_checkout_session_id
invoice, _created = Invoice.objects.update_or_create(
**lookup,
defaults=defaults,
)
if stripe_invoice_id and invoice.stripe_invoice_id != stripe_invoice_id:
invoice.stripe_invoice_id = stripe_invoice_id
invoice.save(update_fields=["stripe_invoice_id", "last_modified"])
return invoice
@transaction.atomic
def upsert_payment_from_stripe(
*,
user,
invoice: Invoice | None,
amount: int,
currency: str = "usd",
status: str,
stripe_payment_intent_id: str | None = None,
stripe_charge_id: str | None = None,
paid_at=None,
failure_message: str = "",
) -> Payment:
if not stripe_payment_intent_id and not stripe_charge_id:
raise ValueError("Need stripe_payment_intent_id or stripe_charge_id")
lookup: dict[str, Any] = {}
if stripe_payment_intent_id:
lookup["stripe_payment_intent_id"] = stripe_payment_intent_id
else:
lookup["stripe_charge_id"] = stripe_charge_id
defaults = {
"user": user,
"company": getattr(user, "company", None),
"invoice": invoice,
"provider": Payment.Provider.STRIPE,
"status": status,
"currency": (currency or "usd").lower(),
"amount": int(amount or 0),
"paid_at": paid_at,
"failure_message": failure_message or "",
}
if stripe_payment_intent_id:
defaults["stripe_payment_intent_id"] = stripe_payment_intent_id
if stripe_charge_id:
defaults["stripe_charge_id"] = stripe_charge_id
payment, _created = Payment.objects.update_or_create(
**lookup,
defaults=defaults,
)
return payment
def handle_checkout_session_completed(session: dict[str, Any]) -> Invoice | None:
metadata = session.get("metadata") or {}
customer_details = session.get("customer_details") or {}
user = _user_from_metadata(
metadata,
email=customer_details.get("email") or session.get("customer_email"),
)
if user is None and session.get("client_reference_id"):
user = _resolve_user(user_id=session.get("client_reference_id"))
if user is None:
logger.error(
"checkout.session.completed: cannot resolve user for session %s",
session.get("id"),
)
return None
amount_total = int(session.get("amount_total") or 0)
invoice = upsert_invoice_from_stripe(
user=user,
stripe_checkout_session_id=session.get("id"),
stripe_subscription_id=session.get("subscription") or None,
stripe_customer_id=session.get("customer") or None,
status=(
Invoice.Status.PAID
if session.get("payment_status") == "paid"
else Invoice.Status.OPEN
),
amount_due=amount_total,
amount_paid=amount_total if session.get("payment_status") == "paid" else 0,
currency=(session.get("currency") or "usd").lower(),
description="Subscription checkout",
)
payment_intent = session.get("payment_intent")
if payment_intent and session.get("payment_status") == "paid":
upsert_payment_from_stripe(
user=user,
invoice=invoice,
amount=amount_total,
currency=(session.get("currency") or "usd").lower(),
status=Payment.Status.SUCCEEDED,
stripe_payment_intent_id=(
payment_intent if isinstance(payment_intent, str) else None
),
paid_at=timezone.now(),
)
if session.get("payment_status") == "paid" or session.get("subscription"):
assign_plan_from_stripe(
user,
plan_slug=metadata.get("plan_slug"),
stripe_subscription_id=session.get("subscription") or "",
)
return invoice
def handle_invoice_paid(stripe_invoice: dict[str, Any]) -> Invoice | None:
metadata = stripe_invoice.get("metadata") or {}
user = _user_from_metadata(
metadata,
email=stripe_invoice.get("customer_email"),
)
if user is None:
existing = None
if stripe_invoice.get("id"):
existing = (
Invoice.objects.filter(stripe_invoice_id=stripe_invoice["id"])
.select_related("user")
.first()
)
if existing is None and stripe_invoice.get("subscription"):
existing = (
Invoice.objects.filter(
stripe_subscription_id=stripe_invoice["subscription"]
)
.select_related("user")
.order_by("-created")
.first()
)
if existing:
user = existing.user
if user is None:
logger.error(
"invoice.paid: cannot resolve user for invoice %s",
stripe_invoice.get("id"),
)
return None
invoice = upsert_invoice_from_stripe(
user=user,
stripe_invoice=stripe_invoice,
status=Invoice.Status.PAID,
)
payment_intent = stripe_invoice.get("payment_intent")
charge = stripe_invoice.get("charge")
if payment_intent or charge:
paid_at = _ts_to_dt(
(stripe_invoice.get("status_transitions") or {}).get("paid_at")
) or timezone.now()
upsert_payment_from_stripe(
user=user,
invoice=invoice,
amount=int(stripe_invoice.get("amount_paid") or 0),
currency=(stripe_invoice.get("currency") or "usd").lower(),
status=Payment.Status.SUCCEEDED,
stripe_payment_intent_id=(
payment_intent if isinstance(payment_intent, str) else None
),
stripe_charge_id=charge if isinstance(charge, str) else None,
paid_at=paid_at,
)
assign_plan_from_stripe(
user,
plan_slug=metadata.get("plan_slug"),
stripe_subscription_id=stripe_invoice.get("subscription") or "",
)
return invoice
def handle_invoice_payment_failed(stripe_invoice: dict[str, Any]) -> Invoice | None:
metadata = stripe_invoice.get("metadata") or {}
user = _user_from_metadata(
metadata,
email=stripe_invoice.get("customer_email"),
)
if user is None:
existing = (
Invoice.objects.filter(stripe_invoice_id=stripe_invoice.get("id"))
.select_related("user")
.first()
)
if existing:
user = existing.user
if user is None:
logger.error(
"invoice.payment_failed: cannot resolve user for invoice %s",
stripe_invoice.get("id"),
)
return None
invoice = upsert_invoice_from_stripe(
user=user,
stripe_invoice=stripe_invoice,
status=Invoice.Status.PAYMENT_FAILED,
)
payment_intent = stripe_invoice.get("payment_intent")
if payment_intent:
upsert_payment_from_stripe(
user=user,
invoice=invoice,
amount=int(stripe_invoice.get("amount_due") or 0),
currency=(stripe_invoice.get("currency") or "usd").lower(),
status=Payment.Status.FAILED,
stripe_payment_intent_id=(
payment_intent if isinstance(payment_intent, str) else None
),
failure_message="Stripe invoice payment failed",
)
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")
data_object = (event.get("data") or {}).get("object") or {}
if event_type == "checkout.session.completed":
return handle_checkout_session_completed(data_object)
if event_type == "invoice.paid":
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