Arag / app /services /billing_service.py
AuthorBot
Allow authors to renew and refill tokens when the current plan budget runs out.
e1383a0
Raw
History Blame Contribute Delete
69.8 kB
"""Author RAG — Stripe Billing Service.
Full Stripe integration — checkout, portal, webhooks, referral coupons.
"""
from __future__ import annotations
import secrets
import structlog
from datetime import datetime, timedelta, timezone
try:
import stripe
except ImportError: # pragma: no cover
stripe = None # type: ignore[assignment]
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.repositories.access_repo import AccessRepository
from app.repositories.user_repo import UserRepository
from app.services.auth_service import _hash_password
logger = structlog.get_logger(__name__)
def _refresh_stripe_key() -> None:
"""Load the current Stripe secret key (supports SuperAdmin hot-reload)."""
if stripe is None:
raise RuntimeError("Stripe SDK not installed")
key = get_settings().STRIPE_SECRET_KEY
if not key:
raise ValueError("Stripe is not configured on this server")
stripe.api_key = key
def _stripe_resource_id(value: object | None) -> str:
"""Normalize Stripe customer/subscription fields (id string or expanded object)."""
if value is None:
raise ValueError("Stripe checkout session is missing billing details. Please contact support.")
if isinstance(value, str):
return value
resource_id = getattr(value, "id", None)
if not resource_id:
raise ValueError("Stripe checkout session is incomplete. Please contact support.")
return str(resource_id)
def _customer_email_from_session(session: object) -> str:
"""Extract payer email from a Stripe Checkout session."""
details = getattr(session, "customer_details", None)
email = getattr(details, "email", None) if details else None
if not email:
raise ValueError(
"No email on this payment session. Use the same email in Stripe Checkout or contact support."
)
return str(email).lower().strip()
def _stripe_metadata_value(metadata: object | None, key: str, default: str = "starter") -> str:
"""Read metadata from Stripe StripeObject or plain dict."""
if not metadata:
return default
if isinstance(metadata, dict):
value = metadata.get(key)
return str(value) if value else default
try:
value = metadata[key] # StripeObject supports __getitem__
return str(value) if value else default
except (KeyError, TypeError):
value = getattr(metadata, key, None)
return str(value) if value else default
# Configure Stripe SDK when available. Refreshed again before each API call.
if stripe and get_settings().STRIPE_SECRET_KEY:
stripe.api_key = get_settings().STRIPE_SECRET_KEY
# ── Webhook Signature Verification ───────────────────────────────────────────
def verify_webhook_signature(payload: bytes, sig_header: str):
"""Verify Stripe webhook signature and return the parsed event."""
if stripe is None:
raise RuntimeError("Stripe SDK not installed")
try:
event = stripe.Webhook.construct_event(
payload=payload,
sig_header=sig_header,
secret=get_settings().STRIPE_WEBHOOK_SECRET,
)
return event
except stripe.SignatureVerificationError as e:
logger.warning("Invalid Stripe webhook signature", error=str(e))
raise
except ValueError as e:
logger.warning("Malformed Stripe webhook payload", error=str(e))
raise
# ── Checkout ──────────────────────────────────────────────────────────────────
async def create_checkout_session(
plan_id: str,
stripe_price_id: str,
success_url: str,
cancel_url: str,
customer_email: str | None = None,
existing_customer_id: str | None = None,
coupon_code: str | None = None,
author_id: str | None = None,
) -> str:
"""Create a Stripe Checkout session and return the redirect URL.
Card always required upfront — no free access without payment.
Args:
plan_id: Our internal plan ID (e.g. "starter").
stripe_price_id: The Stripe Price ID for the selected plan/billing.
success_url: Redirect after payment (must include {CHECKOUT_SESSION_ID}).
cancel_url: Redirect if user cancels.
customer_email: Pre-fill email in Stripe Checkout.
existing_customer_id: For upgrades — attach to existing Stripe customer.
coupon_code: Referral/affiliate coupon to apply automatically.
author_id: Logged-in author id (metadata + client_reference_id).
Returns:
Stripe Checkout URL — redirect the browser here.
"""
metadata: dict[str, str] = {"plan_id": plan_id}
if author_id:
metadata["author_id"] = author_id
params: dict = {
"mode": "subscription",
"line_items": [{"price": stripe_price_id, "quantity": 1}],
"success_url": success_url,
"cancel_url": cancel_url,
"payment_method_collection": "always", # Card required upfront
"metadata": metadata,
"subscription_data": {"metadata": metadata},
"allow_promotion_codes": True, # Enable referral coupons
}
if author_id:
params["client_reference_id"] = author_id
if coupon_code:
params["discounts"] = [{"coupon": coupon_code}]
params.pop("allow_promotion_codes", None)
if existing_customer_id:
params["customer"] = existing_customer_id
elif customer_email:
params["customer_email"] = customer_email
try:
_refresh_stripe_key()
session = stripe.checkout.Session.create(**params)
logger.info("Stripe checkout session created", session_id=session.id, plan_id=plan_id)
return session.url
except stripe.StripeError as e:
logger.error("Stripe checkout failed", error=str(e), plan_id=plan_id)
raise
async def create_addon_checkout_session(
*,
addon_id: str,
price_usd: float,
display_name: str,
author_id: str,
success_url: str,
cancel_url: str,
customer_email: str | None = None,
existing_customer_id: str | None = None,
description: str | None = None,
extra_metadata: dict[str, str] | None = None,
) -> str:
"""Create a one-time Stripe Checkout session for a platform add-on (R-202).
Uses price_data so SuperAdmin can change USD without syncing Stripe Price IDs.
"""
unit_amount = int(round(float(price_usd) * 100))
if unit_amount < 50:
raise ValueError("Add-on price must be at least $0.50 for Stripe Checkout")
metadata: dict[str, str] = {
"addon": addon_id,
"author_id": author_id,
}
if extra_metadata:
metadata.update({k: str(v) for k, v in extra_metadata.items() if v is not None})
params: dict = {
"mode": "payment",
"line_items": [
{
"quantity": 1,
"price_data": {
"currency": "usd",
"unit_amount": unit_amount,
"product_data": {
"name": display_name or "Website URL unlock",
"description": description
or "One-time unlock credit for a locked embed website URL",
},
},
}
],
"success_url": success_url,
"cancel_url": cancel_url,
"metadata": metadata,
"payment_intent_data": {
"metadata": metadata,
},
}
if existing_customer_id:
params["customer"] = existing_customer_id
elif customer_email:
params["customer_email"] = customer_email
try:
_refresh_stripe_key()
session = stripe.checkout.Session.create(**params)
logger.info(
"Stripe addon checkout created",
session_id=session.id,
addon=addon_id,
author_id=author_id,
)
return session.url
except stripe.StripeError as e:
logger.error("Stripe addon checkout failed", error=str(e), addon=addon_id)
raise
async def get_checkout_session(session_id: str) -> stripe.checkout.Session:
"""Retrieve and verify a completed Stripe Checkout session.
Called by POST /api/auth/activate after the browser returns from Stripe.
"""
try:
_refresh_stripe_key()
session = stripe.checkout.Session.retrieve(
session_id,
expand=["customer", "subscription", "subscription.items.data.price"],
)
logger.info(
"Stripe session retrieved",
session_id=session_id,
payment_status=session.payment_status,
)
return session
except stripe.StripeError as e:
logger.error("Failed to retrieve Stripe session", session_id=session_id, error=str(e))
raise
# ── Customer Portal ───────────────────────────────────────────────────────────
async def create_portal_session(stripe_customer_id: str, return_url: str) -> str:
"""Create a Stripe Customer Portal session for self-service billing.
Authors can: update card, view invoices, cancel, upgrade/downgrade.
"""
try:
session = stripe.billing_portal.Session.create(
customer=stripe_customer_id,
return_url=return_url,
)
logger.info("Stripe portal session created", customer_id=stripe_customer_id)
return session.url
except stripe.StripeError as e:
logger.error("Failed to create portal session", customer_id=stripe_customer_id, error=str(e))
raise
# ── Subscription Management ───────────────────────────────────────────────────
async def get_subscription(subscription_id: str) -> stripe.Subscription:
"""Retrieve full subscription details from Stripe."""
return stripe.Subscription.retrieve(
subscription_id,
expand=["items.data.price", "customer"],
)
async def cancel_subscription(subscription_id: str, at_period_end: bool = True) -> None:
"""Cancel a Stripe subscription.
Args:
at_period_end: True = access continues to end of paid period (recommended).
False = immediate cancellation with proration.
"""
try:
stripe.Subscription.modify(subscription_id, cancel_at_period_end=at_period_end)
logger.info("Subscription cancel scheduled", sub_id=subscription_id, at_end=at_period_end)
except stripe.StripeError as e:
logger.error("Failed to cancel subscription", sub_id=subscription_id, error=str(e))
raise
async def upgrade_subscription(
subscription_id: str,
new_stripe_price_id: str,
new_plan_id: str | None = None,
) -> None:
"""Upgrade or downgrade a subscription. Stripe handles proration automatically."""
try:
_refresh_stripe_key()
sub = stripe.Subscription.retrieve(subscription_id)
modify_kwargs: dict = {
"items": [{"id": sub["items"]["data"][0]["id"], "price": new_stripe_price_id}],
"proration_behavior": "create_prorations",
}
if new_plan_id:
modify_kwargs["metadata"] = {"plan_id": new_plan_id}
stripe.Subscription.modify(subscription_id, **modify_kwargs)
logger.info(
"Subscription upgraded",
sub_id=subscription_id,
new_price=new_stripe_price_id,
plan_id=new_plan_id,
)
except stripe.StripeError as e:
logger.error("Failed to upgrade subscription", sub_id=subscription_id, error=str(e))
raise
# ── Account Activation (Post-Payment) ────────────────────────────────────────
class BillingService:
"""Orchestrates account creation and access provisioning after Stripe payment."""
def __init__(self, db: AsyncSession) -> None:
self._db = db
self._access = AccessRepository(db)
self._users = UserRepository(db)
@staticmethod
def _generate_temp_password() -> str:
"""Generate a secure temporary password for webhook auto-provision."""
alphabet = "abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"
return "".join(secrets.choice(alphabet) for _ in range(14))
async def _resolve_plan(self, plan_id: str) -> tuple[str, int]:
"""Return plan_id and token budget from PricingPlan or defaults."""
from app.models.pricing_plan import PricingPlan
from sqlalchemy import select
plan_result = await self._db.execute(
select(PricingPlan).where(PricingPlan.id == plan_id)
)
plan = plan_result.scalar_one_or_none()
if not plan:
logger.warning("Unknown plan_id in Stripe session", plan_id=plan_id)
return "starter", 500_000
return plan_id, plan.max_token_budget
async def _find_user_for_stripe_activation(
self,
email: str,
stripe_session_id: str,
stripe_customer_id: str,
stripe_sub_id: str | None = None,
):
"""Locate an author by email or Stripe checkout identifiers."""
from sqlalchemy import select
from app.models.client_access import ClientAccess
from app.models.user import User
user = await self._users.get_by_email(email)
if user:
return user
for column, value in (
(User.stripe_checkout_session_id, stripe_session_id),
(User.stripe_customer_id, stripe_customer_id),
(User.stripe_subscription_id, stripe_sub_id),
):
if not value:
continue
result = await self._db.execute(select(User).where(column == value))
found = result.scalar_one_or_none()
if found:
return found
if stripe_sub_id:
access_result = await self._db.execute(
select(ClientAccess).where(ClientAccess.stripe_subscription_id == stripe_sub_id)
)
access = access_result.scalar_one_or_none()
if access:
return await self._users.get_by_id(access.author_id)
return None
async def _create_author_for_paid_session(
self,
*,
email: str,
password_hash: str,
full_name: str,
plan_id: str,
token_budget: int,
stripe_customer_id: str,
stripe_sub_id: str,
stripe_session_id: str,
):
"""Create author + access; commit user first so payment is never orphaned."""
user_data: dict = {
"email": email,
"password_hash": password_hash,
"full_name": full_name or None,
"role": "author",
"is_active": True,
"stripe_customer_id": stripe_customer_id,
"stripe_subscription_id": stripe_sub_id,
"stripe_plan_id": plan_id,
"stripe_checkout_session_id": stripe_session_id,
"activation_status": "active",
"provisioned_via": "stripe_activate",
"password_change_required": False,
}
try:
user = await self._users.create(user_data)
await self._db.commit()
except IntegrityError:
await self._db.rollback()
existing = await self._find_user_for_stripe_activation(
email, stripe_session_id, stripe_customer_id, stripe_sub_id
)
if existing:
return existing
minimal = {k: v for k, v in user_data.items() if k not in (
"activation_status", "provisioned_via", "password_change_required",
"stripe_checkout_session_id",
)}
user = await self._users.create(minimal)
await self._db.commit()
ok = await self._ensure_stripe_client_access(
author_id=user.id,
plan_id=plan_id,
token_budget=token_budget,
stripe_sub_id=stripe_sub_id,
stripe_customer_id=stripe_customer_id,
)
if not ok:
logger.error("access_grant_failed_after_user_create", user_id=user.id)
return user
async def _resolve_access_record(self, author_id: str, stripe_sub_id: str):
"""Find the ClientAccess row to update — active, revoked, or by subscription id."""
from sqlalchemy import select
from app.models.client_access import ClientAccess
if stripe_sub_id:
result = await self._db.execute(
select(ClientAccess).where(ClientAccess.stripe_subscription_id == stripe_sub_id)
)
by_sub = result.scalar_one_or_none()
if by_sub:
if by_sub.author_id != author_id:
user = await self._users.get_by_id(author_id)
if user and user.stripe_subscription_id == stripe_sub_id:
logger.warning(
"stripe_access_reclaimed",
sub_id=stripe_sub_id,
from_author=by_sub.author_id,
to_author=author_id,
)
by_sub.author_id = author_id
if by_sub.author_id == author_id:
return by_sub
access = await self._access.get_active_for_author(author_id)
if access:
return access
result = await self._db.execute(
select(ClientAccess)
.where(ClientAccess.author_id == author_id)
.order_by(ClientAccess.created_at.desc())
.limit(1)
)
return result.scalar_one_or_none()
async def _reclaim_access_for_subscriber(
self,
*,
author_id: str,
stripe_sub_id: str,
plan_id: str,
token_budget: int,
):
"""Attach the subscription access row to the paying author."""
from sqlalchemy import select
from app.models.client_access import ClientAccess
if not stripe_sub_id:
return None
result = await self._db.execute(
select(ClientAccess).where(ClientAccess.stripe_subscription_id == stripe_sub_id)
)
access = result.scalar_one_or_none()
if not access:
return None
if access.author_id != author_id:
logger.warning(
"stripe_access_reclaimed",
sub_id=stripe_sub_id,
from_author=access.author_id,
to_author=author_id,
)
access.author_id = author_id
now = datetime.now(timezone.utc)
access.plan = plan_id
access.plan_id = plan_id
access.token_budget = token_budget
access.is_revoked = False
access.auto_renew = True
exp = access.expires_at
compare_now = now.replace(tzinfo=None) if (exp and exp.tzinfo is None) else now
if exp is None or exp < compare_now:
access.expires_at = now + timedelta(days=365 * 10)
return access
async def _sync_user_stripe_fields(
self,
author_id: str,
*,
plan_id: str,
stripe_sub_id: str,
stripe_customer_id: str | None = None,
) -> None:
"""Keep User Stripe columns aligned with the paid subscription."""
from sqlalchemy import update
from app.models.user import User
values: dict = {
"stripe_plan_id": plan_id,
"stripe_subscription_id": stripe_sub_id,
"chatbot_is_active": True,
"activation_status": "active",
}
if stripe_customer_id:
values["stripe_customer_id"] = stripe_customer_id
await self._db.execute(
update(User).where(User.id == author_id).values(**values)
)
async def _purge_conflicting_subscription_rows(
self, author_id: str, stripe_sub_id: str,
) -> None:
"""Remove orphan grants on other authors that block this subscription id."""
from sqlalchemy import delete
from app.models.client_access import ClientAccess
if not stripe_sub_id:
return
await self._db.execute(
delete(ClientAccess).where(
ClientAccess.stripe_subscription_id == stripe_sub_id,
ClientAccess.author_id != author_id,
)
)
await self._db.flush()
async def _sync_from_stored_checkout_session(self, user_id: str) -> bool:
"""Reconcile grant from stripe_checkout_session_id when User Stripe fields are incomplete."""
user = await self._users.get_by_id(user_id)
if not user or not user.stripe_checkout_session_id:
return False
try:
session = await get_checkout_session(user.stripe_checkout_session_id)
except Exception as exc:
logger.warning("checkout_session_sync_failed", user_id=user_id, error=str(exc)[:120])
return False
if session.payment_status != "paid":
return False
stripe_customer_id = _stripe_resource_id(session.customer)
stripe_sub_id = _stripe_resource_id(session.subscription)
plan_id, token_budget = await self._resolve_plan(
_stripe_metadata_value(session.metadata, "plan_id", user.stripe_plan_id or "starter")
)
return await self._ensure_stripe_client_access(
author_id=user_id,
plan_id=plan_id,
token_budget=token_budget,
stripe_sub_id=stripe_sub_id,
stripe_customer_id=stripe_customer_id,
)
async def _upsert_author_access_row(
self,
*,
author_id: str,
plan_id: str,
token_budget: int,
stripe_sub_id: str,
):
"""Update an existing author grant or create a fresh one."""
from sqlalchemy import select, update
from app.core.access.token_crypto import create_subscription_token, hash_subscription_token
from app.models.base import generate_uuid
from app.models.client_access import ClientAccess
now = datetime.now(timezone.utc)
expires = now + timedelta(days=365 * 10)
await self._purge_conflicting_subscription_rows(author_id, stripe_sub_id)
reclaimed = await self._reclaim_access_for_subscriber(
author_id=author_id,
stripe_sub_id=stripe_sub_id,
plan_id=plan_id,
token_budget=token_budget,
)
if reclaimed:
return reclaimed
access = await self._resolve_access_record(author_id, stripe_sub_id)
if access:
await self._apply_access_grant_updates(
access,
author_id=author_id,
plan_id=plan_id,
token_budget=token_budget,
stripe_sub_id=stripe_sub_id,
)
return access
result = await self._db.execute(
select(ClientAccess)
.where(ClientAccess.author_id == author_id)
.order_by(ClientAccess.created_at.desc())
.limit(1)
)
existing = result.scalar_one_or_none()
if existing:
await self._apply_access_grant_updates(
existing,
author_id=author_id,
plan_id=plan_id,
token_budget=token_budget,
stripe_sub_id=stripe_sub_id,
)
return existing
grant_id = generate_uuid()
token = create_subscription_token(
author_id=author_id,
grant_id=grant_id,
granted_at=now,
expires_at=expires,
)
return await self._access.create({
"id": grant_id,
"author_id": author_id,
"granted_by": author_id, # self-provisioned via Stripe (DB may have NOT NULL)
"plan": plan_id,
"plan_id": plan_id,
"stripe_subscription_id": stripe_sub_id,
"granted_at": now,
"expires_at": expires,
"token_hash": hash_subscription_token(token),
"token_budget": token_budget,
"auto_renew": True,
})
async def _ensure_stripe_client_access(
self,
*,
author_id: str,
plan_id: str,
token_budget: int,
stripe_sub_id: str,
stripe_customer_id: str | None = None,
) -> bool:
"""Idempotent: create or reactivate ClientAccess for a paid Stripe author."""
await self._sync_user_stripe_fields(
author_id,
plan_id=plan_id,
stripe_sub_id=stripe_sub_id,
stripe_customer_id=stripe_customer_id,
)
try:
await self._upsert_author_access_row(
author_id=author_id,
plan_id=plan_id,
token_budget=token_budget,
stripe_sub_id=stripe_sub_id,
)
await self._db.commit()
except IntegrityError as exc:
await self._db.rollback()
logger.warning(
"stripe_access_upsert_retry",
author_id=author_id,
sub_id=stripe_sub_id,
error=str(exc)[:120],
)
try:
await self._upsert_author_access_row(
author_id=author_id,
plan_id=plan_id,
token_budget=token_budget,
stripe_sub_id=stripe_sub_id,
)
await self._db.commit()
except IntegrityError:
await self._db.rollback()
logger.error(
"stripe_access_create_failed",
author_id=author_id,
sub_id=stripe_sub_id,
)
return False
active = await self._access.get_active_for_author(author_id)
if active:
return True
logger.error(
"stripe_access_inactive_after_upsert",
author_id=author_id,
sub_id=stripe_sub_id,
)
return False
async def _sync_subscription_from_stripe(self, user_id: str) -> bool:
"""Fetch live Stripe subscription for a customer and ensure local grant."""
user = await self._users.get_by_id(user_id)
if not user:
return False
stripe_sub_id = user.stripe_subscription_id
plan_id = user.stripe_plan_id or "starter"
stripe_customer_id = user.stripe_customer_id
if user.stripe_customer_id and not stripe_sub_id:
try:
_refresh_stripe_key()
subs = stripe.Subscription.list(
customer=user.stripe_customer_id,
status="active",
limit=1,
)
if subs.data:
stripe_sub_id = subs.data[0].id
meta = subs.data[0].metadata or {}
plan_id = _stripe_metadata_value(meta, "plan_id", plan_id)
except Exception as exc:
logger.warning("stripe_subscription_sync_failed", user_id=user_id, error=str(exc)[:120])
if not stripe_sub_id:
return False
_, token_budget = await self._resolve_plan(plan_id)
return await self._ensure_stripe_client_access(
author_id=user_id,
plan_id=plan_id,
token_budget=token_budget,
stripe_sub_id=stripe_sub_id,
stripe_customer_id=stripe_customer_id,
)
async def require_active_subscription(self, author_id: str) -> bool:
"""Repair and verify an author has an active ClientAccess grant."""
if await self._access.get_active_for_author(author_id):
return True
if await self.repair_missing_access_for_user(author_id):
return True
return await self._sync_subscription_from_stripe(author_id)
async def repair_missing_access_for_user(self, user_id: str) -> bool:
"""Backfill ClientAccess when User has Stripe fields but no active grant."""
user = await self._users.get_by_id(user_id)
if not user:
return False
if not user.stripe_subscription_id:
if await self._sync_from_stored_checkout_session(user_id):
return True
if await self._sync_subscription_from_stripe(user_id):
return True
return False
plan_id = user.stripe_plan_id or "starter"
stripe_sub_id = user.stripe_subscription_id
_, token_budget = await self._resolve_plan(plan_id)
ok = await self._ensure_stripe_client_access(
author_id=user_id,
plan_id=plan_id,
token_budget=token_budget,
stripe_sub_id=stripe_sub_id,
stripe_customer_id=user.stripe_customer_id,
)
if ok:
return True
return await self._access.get_active_for_author(user_id) is not None
async def _apply_access_grant_updates(
self,
access,
*,
author_id: str,
plan_id: str,
token_budget: int,
stripe_sub_id: str,
) -> None:
"""Update an existing ClientAccess row for a Stripe subscriber."""
now = datetime.now(timezone.utc)
access.plan = plan_id
access.plan_id = plan_id
access.token_budget = token_budget
access.is_revoked = False
access.auto_renew = True
if stripe_sub_id and not access.stripe_subscription_id:
access.stripe_subscription_id = stripe_sub_id
exp = access.expires_at
compare_now = now.replace(tzinfo=None) if (exp and exp.tzinfo is None) else now
if exp is None or exp < compare_now:
access.expires_at = now + timedelta(days=365 * 10)
if access.author_id != author_id:
access.author_id = author_id
async def _force_password_login(
self,
*,
customer_email: str,
password_hash: str,
full_name: str | None,
stripe_session_id: str,
stripe_customer_id: str,
stripe_sub_id: str,
plan_id: str,
token_budget: int = 500_000,
full_name_for_create: str = "",
) -> dict:
"""Guaranteed post-payment path: set password and return login payload."""
user = await self._find_user_for_stripe_activation(
customer_email, stripe_session_id, stripe_customer_id, stripe_sub_id
)
if not user:
user = await self._users.get_by_email(customer_email)
if not user:
user = await self._create_author_for_paid_session(
email=customer_email,
password_hash=password_hash,
full_name=full_name_for_create or full_name or customer_email,
plan_id=plan_id,
token_budget=token_budget,
stripe_customer_id=stripe_customer_id,
stripe_sub_id=stripe_sub_id,
stripe_session_id=stripe_session_id,
)
return {
"user_id": user.id,
"email": user.email,
"plan_id": plan_id,
"already_activated": False,
}
user.password_hash = password_hash
user.password_change_required = False
user.activation_status = "active"
user.is_active = True
user.provisioned_via = "stripe_activate"
user.stripe_customer_id = stripe_customer_id
user.stripe_subscription_id = stripe_sub_id
user.stripe_plan_id = plan_id
if full_name:
user.full_name = full_name
if not user.stripe_checkout_session_id:
user.stripe_checkout_session_id = stripe_session_id
try:
await self._db.commit()
except IntegrityError:
await self._db.rollback()
user = await self._users.get_by_email(customer_email)
if not user:
raise
user.password_hash = password_hash
user.password_change_required = False
user.activation_status = "active"
user.is_active = True
if full_name:
user.full_name = full_name
await self._db.commit()
user_id = user.id
user_email = user.email
ok = await self._ensure_stripe_client_access(
author_id=user_id,
plan_id=plan_id,
token_budget=token_budget,
stripe_sub_id=stripe_sub_id,
stripe_customer_id=stripe_customer_id,
)
if not ok:
logger.error("access_grant_failed_force_login", user_id=user_id)
logger.info("Author password set after Stripe payment", user_id=user_id, email=user_email)
return {
"user_id": user_id,
"email": user_email,
"plan_id": plan_id,
"already_activated": True,
}
async def _complete_existing_user_activation(
self,
user,
*,
stripe_session_id: str,
password_hash: str,
full_name: str | None,
plan_id: str,
token_budget: int,
stripe_customer_id: str,
stripe_sub_id: str,
) -> dict:
"""Attach Stripe payment to an existing author and apply their chosen password."""
now = datetime.now(timezone.utc)
was_same_session = user.stripe_checkout_session_id == stripe_session_id
user.password_hash = password_hash
user.password_change_required = False
user.provisioned_via = "stripe_activate"
user.activation_status = "active"
user.stripe_checkout_session_id = stripe_session_id
user.stripe_customer_id = stripe_customer_id
user.stripe_subscription_id = stripe_sub_id
user.stripe_plan_id = plan_id
if full_name:
user.full_name = full_name
user.credentials_email_sent_at = now
try:
ok = await self._ensure_stripe_client_access(
author_id=user.id,
plan_id=plan_id,
token_budget=token_budget,
stripe_sub_id=stripe_sub_id,
stripe_customer_id=stripe_customer_id,
)
if not ok:
raise RuntimeError("subscription_grant_failed")
except (IntegrityError, RuntimeError):
await self._db.rollback()
return await self._force_password_login(
customer_email=user.email,
password_hash=password_hash,
full_name=full_name,
stripe_session_id=stripe_session_id,
stripe_customer_id=stripe_customer_id,
stripe_sub_id=stripe_sub_id,
plan_id=plan_id,
token_budget=token_budget,
full_name_for_create=user.full_name or user.email,
)
self._queue_credentials_email(
email=user.email,
name=user.full_name or user.email,
temp_password=None,
user_chose_password=True,
)
logger.info(
"Existing author linked to Stripe checkout",
user_id=user.id,
email=user.email,
plan_id=plan_id,
same_session=was_same_session,
)
return {
"user_id": user.id,
"email": user.email,
"plan_id": plan_id,
"already_activated": was_same_session,
}
async def _provision_stripe_author(
self,
*,
email: str,
password_hash: str,
full_name: str,
plan_id: str,
token_budget: int,
stripe_customer_id: str,
stripe_sub_id: str,
stripe_session_id: str,
provisioned_via: str,
activation_status: str,
password_change_required: bool,
) -> dict:
"""Create User + ClientAccess for a paid Stripe checkout."""
from app.models.base import generate_uuid
from app.core.access.token_crypto import create_subscription_token, hash_subscription_token
now = datetime.now(timezone.utc)
user = await self._users.create({
"email": email,
"password_hash": password_hash,
"full_name": full_name or None,
"role": "author",
"is_active": True,
"stripe_customer_id": stripe_customer_id,
"stripe_subscription_id": stripe_sub_id,
"stripe_plan_id": plan_id,
"stripe_checkout_session_id": stripe_session_id,
"activation_status": activation_status,
"provisioned_via": provisioned_via,
"password_change_required": password_change_required,
})
grant_id = generate_uuid()
token = create_subscription_token(
author_id=user.id,
grant_id=grant_id,
granted_at=now,
expires_at=now + timedelta(days=365 * 10),
)
await self._access.create({
"id": grant_id,
"author_id": user.id,
"granted_by": user.id, # self-provisioned via Stripe (DB may have NOT NULL)
"plan": plan_id,
"plan_id": plan_id,
"stripe_subscription_id": stripe_sub_id,
"granted_at": now,
"expires_at": now + timedelta(days=365 * 10),
"token_hash": hash_subscription_token(token),
"token_budget": token_budget,
"auto_renew": True,
})
await self._db.commit()
return {
"user_id": user.id,
"email": email,
"plan_id": plan_id,
"subscription_token": token,
"already_activated": False,
"provisioned_via": provisioned_via,
}
def _queue_credentials_email(
self,
*,
email: str,
name: str,
temp_password: str | None,
user_chose_password: bool,
) -> None:
"""Queue credentials email via Celery (non-blocking)."""
from app.services.author_onboarding_service import login_url
from app.tasks.email_task import send_account_credentials as send_task
try:
send_task.delay({
"to": email,
"name": name or email,
"login_url": login_url(),
"temp_password": temp_password,
"user_chose_password": user_chose_password,
})
except Exception as exc:
logger.warning("credentials_email_queue_failed", email=email, error=str(exc)[:200])
async def _verify_post_activation(
self,
user_id: str,
*,
plan_id: str,
token_budget: int,
stripe_sub_id: str,
stripe_customer_id: str | None = None,
) -> None:
"""Fail closed when a paid author has no active ClientAccess grant."""
if not await self.require_active_subscription(user_id):
await self._ensure_stripe_client_access(
author_id=user_id,
plan_id=plan_id,
token_budget=token_budget,
stripe_sub_id=stripe_sub_id,
stripe_customer_id=stripe_customer_id,
)
if not await self._access.get_active_for_author(user_id):
raise RuntimeError("Subscription grant missing after Stripe payment")
async def activate_from_session(
self,
stripe_session_id: str,
password_hash: str,
full_name: str | None = None,
) -> dict:
"""Create or upgrade User + ClientAccess from a verified Stripe Checkout session.
Paid checkout always ends with the author able to log in with their chosen password.
"""
session = await get_checkout_session(stripe_session_id)
if session.payment_status != "paid":
raise ValueError(f"Payment not completed. Status: {session.payment_status}")
customer_email = _customer_email_from_session(session)
stripe_customer_id = _stripe_resource_id(session.customer)
stripe_sub_id = _stripe_resource_id(session.subscription)
plan_id, token_budget = await self._resolve_plan(
_stripe_metadata_value(session.metadata, "plan_id", "starter")
)
customer_name = full_name or getattr(session.customer_details, "name", "") or ""
existing_user = await self._find_user_for_stripe_activation(
customer_email,
stripe_session_id,
stripe_customer_id,
stripe_sub_id,
)
try:
if existing_user:
result = await self._complete_existing_user_activation(
existing_user,
stripe_session_id=stripe_session_id,
password_hash=password_hash,
full_name=full_name,
plan_id=plan_id,
token_budget=token_budget,
stripe_customer_id=stripe_customer_id,
stripe_sub_id=stripe_sub_id,
)
await self._verify_post_activation(
result["user_id"],
plan_id=plan_id,
token_budget=token_budget,
stripe_sub_id=stripe_sub_id,
stripe_customer_id=stripe_customer_id,
)
return result
user = await self._create_author_for_paid_session(
email=customer_email,
password_hash=password_hash,
full_name=customer_name,
plan_id=plan_id,
token_budget=token_budget,
stripe_customer_id=stripe_customer_id,
stripe_sub_id=stripe_sub_id,
stripe_session_id=stripe_session_id,
)
self._queue_credentials_email(
email=customer_email,
name=customer_name or customer_email,
temp_password=None,
user_chose_password=True,
)
logger.info("Author account activated from Stripe", user_id=user.id, plan_id=plan_id)
result = {
"user_id": user.id,
"email": customer_email,
"plan_id": plan_id,
"already_activated": False,
}
await self._verify_post_activation(
result["user_id"],
plan_id=plan_id,
token_budget=token_budget,
stripe_sub_id=stripe_sub_id,
stripe_customer_id=stripe_customer_id,
)
return result
except IntegrityError:
await self._db.rollback()
logger.warning("Stripe activation race — linking existing account", email=customer_email)
existing_user = await self._find_user_for_stripe_activation(
customer_email,
stripe_session_id,
stripe_customer_id,
stripe_sub_id,
)
if existing_user:
result = await self._complete_existing_user_activation(
existing_user,
stripe_session_id=stripe_session_id,
password_hash=password_hash,
full_name=full_name,
plan_id=plan_id,
token_budget=token_budget,
stripe_customer_id=stripe_customer_id,
stripe_sub_id=stripe_sub_id,
)
else:
result = await self._force_password_login(
customer_email=customer_email,
password_hash=password_hash,
full_name=full_name,
stripe_session_id=stripe_session_id,
stripe_customer_id=stripe_customer_id,
stripe_sub_id=stripe_sub_id,
plan_id=plan_id,
token_budget=token_budget,
full_name_for_create=customer_name,
)
await self._verify_post_activation(
result["user_id"],
plan_id=plan_id,
token_budget=token_budget,
stripe_sub_id=stripe_sub_id,
stripe_customer_id=stripe_customer_id,
)
return result
except Exception as exc:
await self._db.rollback()
logger.warning("Stripe activation fallback", email=customer_email, error=str(exc)[:200])
result = await self._force_password_login(
customer_email=customer_email,
password_hash=password_hash,
full_name=full_name,
stripe_session_id=stripe_session_id,
stripe_customer_id=stripe_customer_id,
stripe_sub_id=stripe_sub_id,
plan_id=plan_id,
token_budget=token_budget,
full_name_for_create=customer_name,
)
await self._verify_post_activation(
result["user_id"],
plan_id=plan_id,
token_budget=token_budget,
stripe_sub_id=stripe_sub_id,
stripe_customer_id=stripe_customer_id,
)
return result
async def handle_embed_unlock_payment(self, stripe_session_id: str) -> dict | None:
"""Grant one embed unlock credit after paid one-time Checkout (R-202).
Idempotent by Stripe session id — safe for webhook + browser confirm.
"""
from datetime import datetime, timezone
from sqlalchemy.exc import IntegrityError
from app.models.stripe_event import StripeEvent
from app.models.user import User
marker_id = f"embed_unlock:{stripe_session_id}"
async def _credits_for(author_id: str) -> dict:
user = await self._db.get(User, author_id) if author_id else None
return {
"author_id": author_id,
"embed_unlock_credits": int(user.embed_unlock_credits) if user else 0,
"already_processed": True,
}
existing = await self._db.get(StripeEvent, marker_id)
if existing and existing.status == "processed":
author_id = ""
try:
import json
payload = json.loads(existing.raw_payload or "{}")
author_id = str(payload.get("author_id") or "")
except Exception:
pass
return await _credits_for(author_id)
session = await get_checkout_session(stripe_session_id)
if session.payment_status != "paid":
logger.warning("embed_unlock skipped — not paid", session_id=stripe_session_id)
return None
mode = getattr(session, "mode", None) or ""
if mode and mode != "payment":
logger.warning("embed_unlock skipped — wrong mode", mode=mode)
return None
addon = _stripe_metadata_value(session.metadata, "addon", "")
if addon != "embed_unlock":
return None
author_id = _stripe_metadata_value(session.metadata, "author_id", "")
if not author_id:
logger.warning("embed_unlock missing author_id", session_id=stripe_session_id)
return None
# Claim idempotency lock BEFORE granting (webhook + browser confirm race)
import json
if not existing:
try:
self._db.add(
StripeEvent(
id=marker_id,
event_type="embed_unlock.payment",
related_object_id=stripe_session_id,
related_object_type="checkout.session",
received_at=datetime.now(timezone.utc),
processed_at=datetime.now(timezone.utc),
status="processed",
raw_payload=json.dumps(
{"author_id": author_id, "addon": "embed_unlock"}
),
)
)
await self._db.flush()
except IntegrityError:
await self._db.rollback()
return await _credits_for(author_id)
from app.services.embed_domain_service import EmbedDomainService
total = await EmbedDomainService(self._db).apply_paid_unlock_credit(author_id)
return {"author_id": author_id, "embed_unlock_credits": total}
async def provision_from_checkout_webhook(self, stripe_session_id: str) -> dict | None:
"""Webhook fallback: auto-provision if user never completes /success page."""
session = await get_checkout_session(stripe_session_id)
if session.payment_status != "paid":
logger.warning("Webhook provision skipped — not paid", session_id=stripe_session_id)
return None
addon = _stripe_metadata_value(session.metadata, "addon", "")
if addon == "embed_unlock":
return await self.handle_embed_unlock_payment(stripe_session_id)
customer_email = _customer_email_from_session(session)
existing = await self._users.get_by_email(customer_email)
stripe_customer_id = _stripe_resource_id(session.customer)
stripe_sub_id = _stripe_resource_id(session.subscription)
plan_id, token_budget = await self._resolve_plan(
_stripe_metadata_value(session.metadata, "plan_id", "starter")
)
if existing:
existing.stripe_customer_id = existing.stripe_customer_id or stripe_customer_id
existing.stripe_subscription_id = existing.stripe_subscription_id or stripe_sub_id
existing.stripe_plan_id = existing.stripe_plan_id or plan_id
if not existing.stripe_checkout_session_id:
existing.stripe_checkout_session_id = stripe_session_id
await self._ensure_stripe_client_access(
author_id=existing.id,
plan_id=plan_id,
token_budget=token_budget,
stripe_sub_id=stripe_sub_id,
)
logger.info("Webhook ensured access for existing user", email=customer_email)
return {"skipped": True, "user_id": existing.id, "access_ensured": True}
temp_password = self._generate_temp_password()
customer_name = getattr(session.customer_details, "name", "") or ""
result = await self._provision_stripe_author(
email=customer_email,
password_hash=_hash_password(temp_password),
full_name=customer_name,
plan_id=plan_id,
token_budget=token_budget,
stripe_customer_id=stripe_customer_id,
stripe_sub_id=stripe_sub_id,
stripe_session_id=stripe_session_id,
provisioned_via="stripe_webhook",
activation_status="pending",
password_change_required=True,
)
user = await self._users.get_by_id(result["user_id"])
if user:
user.credentials_email_sent_at = datetime.now(timezone.utc)
await self._db.commit()
self._queue_credentials_email(
email=customer_email,
name=customer_name or customer_email,
temp_password=temp_password,
user_chose_password=False,
)
logger.info("Author auto-provisioned via webhook", user_id=result["user_id"])
return result
async def handle_payment_succeeded(self, stripe_subscription_id: str) -> None:
"""Reset monthly token counter on successful invoice payment.
Called by: invoice.payment_succeeded webhook.
"""
from sqlalchemy import select, update
from app.models.client_access import ClientAccess
result = await self._db.execute(
select(ClientAccess).where(
ClientAccess.stripe_subscription_id == stripe_subscription_id
)
)
access = result.scalar_one_or_none()
if not access:
logger.warning("No ClientAccess for subscription", sub_id=stripe_subscription_id)
return
# Reset token counter + clear any grace period on the User
access.tokens_used = 0
access.is_revoked = False
from app.models.user import User
user_result = await self._db.execute(
select(User).where(
User.stripe_subscription_id == stripe_subscription_id
)
)
user = user_result.scalar_one_or_none()
if user:
user.payment_grace_until = None
await self._db.commit()
logger.info("Token counter reset on payment success", sub_id=stripe_subscription_id)
async def handle_payment_failed(
self,
stripe_subscription_id: str,
grace_days: int = 7,
) -> None:
"""Set grace period on payment failure — access continues for grace_days.
Called by: invoice.payment_failed webhook.
The dunning email is sent by the webhook handler separately.
"""
from sqlalchemy import select
from app.models.user import User
result = await self._db.execute(
select(User).where(User.stripe_subscription_id == stripe_subscription_id)
)
user = result.scalar_one_or_none()
if user:
user.payment_grace_until = datetime.now(timezone.utc) + timedelta(days=grace_days)
await self._db.commit()
logger.info(
"Payment grace period set",
user_id=user.id,
grace_until=user.payment_grace_until,
)
async def handle_subscription_cancelled(self, stripe_subscription_id: str) -> None:
"""Deactivate access when Stripe subscription is fully cancelled.
Called by: customer.subscription.deleted webhook.
"""
from sqlalchemy import select
from app.models.client_access import ClientAccess
result = await self._db.execute(
select(ClientAccess).where(
ClientAccess.stripe_subscription_id == stripe_subscription_id
)
)
access = result.scalar_one_or_none()
if access:
access.is_revoked = True
access.revoked_at = datetime.now(timezone.utc)
access.revoke_reason = "stripe_subscription_cancelled"
await self._db.commit()
logger.info("Access revoked on subscription cancellation", sub_id=stripe_subscription_id)
async def handle_plan_changed(
self,
stripe_subscription_id: str,
new_plan_id: str,
) -> None:
"""Update plan limits when author upgrades or downgrades.
Called by: customer.subscription.updated webhook.
"""
from sqlalchemy import select
from app.models.client_access import ClientAccess
from app.models.pricing_plan import PricingPlan
from app.models.user import User
# Get new plan limits
plan_result = await self._db.execute(
select(PricingPlan).where(PricingPlan.id == new_plan_id)
)
plan = plan_result.scalar_one_or_none()
if not plan:
logger.warning("Unknown plan on subscription update", plan_id=new_plan_id)
return
# Update ClientAccess budget
access_result = await self._db.execute(
select(ClientAccess).where(
ClientAccess.stripe_subscription_id == stripe_subscription_id
)
)
access = access_result.scalar_one_or_none()
if access:
access.plan = new_plan_id
access.plan_id = new_plan_id
access.token_budget = plan.max_token_budget
# Update User plan reference
user_result = await self._db.execute(
select(User).where(User.stripe_subscription_id == stripe_subscription_id)
)
user = user_result.scalar_one_or_none()
if user:
user.stripe_plan_id = new_plan_id
await self._db.commit()
logger.info(
"Plan updated on subscription change",
sub_id=stripe_subscription_id,
new_plan=new_plan_id,
)
async def _load_active_plan(self, plan_id: str):
"""Load an active PricingPlan or raise ValueError."""
from sqlalchemy import select
from app.models.pricing_plan import PricingPlan
result = await self._db.execute(
select(PricingPlan).where(
PricingPlan.id == plan_id,
PricingPlan.is_active == True, # noqa: E712
)
)
plan = result.scalar_one_or_none()
if not plan:
raise ValueError(f"Plan '{plan_id}' not found or inactive")
return plan
def _stripe_price_for_billing(self, plan, billing: str) -> str:
"""Return Stripe Price ID for monthly/annual billing."""
price_id = (
plan.stripe_price_id_monthly
if billing == "monthly"
else plan.stripe_price_id_annual
)
if not price_id:
raise ValueError(
f"Plan '{plan.id}' is not configured for {billing} billing yet."
)
return price_id
async def _stripe_subscription_is_active(self, subscription_id: str) -> bool:
"""True when Stripe reports the subscription as active or trialing."""
try:
_refresh_stripe_key()
sub = stripe.Subscription.retrieve(subscription_id)
status = getattr(sub, "status", None) or ""
return status in ("active", "trialing", "past_due")
except Exception as exc:
logger.warning(
"stripe_subscription_status_check_failed",
sub_id=subscription_id,
error=str(exc)[:120],
)
return False
async def start_author_plan_action(
self,
author,
plan_id: str,
billing: str = "monthly",
renew: bool = False,
redis=None,
) -> dict:
"""Subscribe, renew/refill, or change plan for a logged-in author (R-184–R-188).
Returns either a Checkout redirect or an in-place upgrade result.
"""
from app.core.public_url import resolve_api_public_base_url
from app.services.token_budget import percent_used, tokens_remaining
plan = await self._load_active_plan(plan_id)
stripe_price_id = self._stripe_price_for_billing(plan, billing)
access = await self._access.get_active_for_author(author.id)
current_plan = (
(author.stripe_plan_id or "")
or (getattr(access, "plan_id", None) or "")
or (getattr(access, "plan", None) or "")
).strip().lower()
target = plan_id.strip().lower()
same_plan = bool(current_plan) and (
current_plan == target or current_plan == plan.display_name.strip().lower()
)
needs_refill = False
if access:
needs_refill = tokens_remaining(access) <= 0 or percent_used(access) >= 80.0
if renew or (same_plan and needs_refill):
return await self._start_plan_renew_refill_checkout(
author=author,
plan=plan,
billing=billing,
)
sub_id = (author.stripe_subscription_id or "").strip()
if sub_id and await self._stripe_subscription_is_active(sub_id):
if same_plan:
raise ValueError(
"You are already on this plan. When your token budget runs low, "
"use Renew & refill tokens on Billing."
)
await upgrade_subscription(sub_id, stripe_price_id, new_plan_id=plan_id)
await self.handle_plan_changed(sub_id, plan_id)
return {
"action": "upgraded",
"plan_id": plan_id,
"plan_name": plan.display_name,
"billing": billing,
}
base = resolve_api_public_base_url()
success_url = (
f"{base}/author?page=billing&checkout=success"
f"&session_id={{CHECKOUT_SESSION_ID}}"
)
cancel_url = f"{base}/author?page=billing&checkout=cancel"
checkout_url = await create_checkout_session(
plan_id=plan_id,
stripe_price_id=stripe_price_id,
success_url=success_url,
cancel_url=cancel_url,
customer_email=None if author.stripe_customer_id else author.email,
existing_customer_id=author.stripe_customer_id,
author_id=author.id,
)
return {
"action": "checkout",
"checkout_url": checkout_url,
"plan_id": plan_id,
"plan_name": plan.display_name,
"billing": billing,
}
def _renew_refill_price_usd(self, plan, billing: str) -> float:
"""USD charge for one renew/refill cycle."""
if billing == "annual":
# annual_price_usd is per-month when billed annually
return float(plan.annual_price_usd or 0) * 12
return float(plan.monthly_price_usd or 0)
async def _start_plan_renew_refill_checkout(
self,
author,
plan,
billing: str = "monthly",
) -> dict:
"""One-time Checkout to refill token budget for the current (or chosen) plan."""
from app.core.public_url import resolve_api_public_base_url
price_usd = self._renew_refill_price_usd(plan, billing)
if price_usd < 0.5:
raise ValueError("This plan has no renew price configured yet.")
base = resolve_api_public_base_url()
success_url = (
f"{base}/author?page=billing&checkout=success"
f"&session_id={{CHECKOUT_SESSION_ID}}"
)
cancel_url = f"{base}/author?page=billing&checkout=cancel"
period = "annual" if billing == "annual" else "monthly"
checkout_url = await create_addon_checkout_session(
addon_id="plan_renew_refill",
price_usd=price_usd,
display_name=f"{plan.display_name} — renew & refill tokens",
author_id=author.id,
success_url=success_url,
cancel_url=cancel_url,
customer_email=None if author.stripe_customer_id else author.email,
existing_customer_id=author.stripe_customer_id,
description=(
f"Refills your {plan.display_name} token budget for one {period} period."
),
extra_metadata={"plan_id": plan.id, "billing": billing},
)
return {
"action": "checkout",
"checkout_url": checkout_url,
"plan_id": plan.id,
"plan_name": plan.display_name,
"billing": billing,
"renew_refill": True,
}
async def apply_plan_renew_refill(
self,
author_id: str,
plan_id: str,
billing: str = "monthly",
redis=None,
) -> dict:
"""Reset token usage and extend access after a paid renew/refill (R-188)."""
from datetime import timedelta
from app.services.token_budget import clear_budget_exhausted
plan = await self._load_active_plan(plan_id)
access = await self._access.get_active_for_author(author_id)
if not access:
from sqlalchemy import select
from app.models.client_access import ClientAccess
result = await self._db.execute(
select(ClientAccess)
.where(ClientAccess.author_id == author_id)
.order_by(ClientAccess.granted_at.desc())
.limit(1)
)
access = result.scalar_one_or_none()
if not access:
raise ValueError("No subscription found to refill. Subscribe to a plan first.")
now = datetime.now(timezone.utc)
days = 365 if billing == "annual" else 30
exp = access.expires_at
if exp is not None and exp.tzinfo is None:
exp = exp.replace(tzinfo=timezone.utc)
base = exp if exp and exp > now else now
access.tokens_used = 0
access.is_revoked = False
access.revoked_at = None
access.revoke_reason = None
access.plan = plan_id
access.plan_id = plan_id
access.token_budget = plan.max_token_budget
access.expires_at = base + timedelta(days=days)
user = await self._users.get_by_id(author_id)
if user:
user.stripe_plan_id = plan_id
user.payment_grace_until = None
user.chatbot_is_active = True
await self._db.commit()
if redis is not None:
try:
await clear_budget_exhausted(redis, author_id)
except Exception as exc:
logger.warning(
"clear_budget_exhausted_failed",
author_id=author_id,
error=str(exc)[:120],
)
logger.info(
"Plan renew refill applied",
author_id=author_id,
plan_id=plan_id,
billing=billing,
)
return {
"ok": True,
"plan_id": plan_id,
"tokens_remaining": plan.max_token_budget,
"expires_at": access.expires_at.isoformat() if access.expires_at else None,
}
async def handle_plan_renew_refill_payment(self, stripe_session_id: str, redis=None) -> dict | None:
"""Apply renew/refill after paid one-time Checkout (webhook or confirm)."""
session = await get_checkout_session(stripe_session_id)
if getattr(session, "payment_status", None) != "paid":
return None
meta = getattr(session, "metadata", None)
addon = _stripe_metadata_value(meta, "addon", "")
if addon != "plan_renew_refill":
return None
author_id = _stripe_metadata_value(meta, "author_id", "")
plan_id = _stripe_metadata_value(meta, "plan_id", "starter")
billing = _stripe_metadata_value(meta, "billing", "monthly")
if not author_id:
logger.warning("plan_renew_refill missing author_id", session_id=stripe_session_id)
return None
return await self.apply_plan_renew_refill(
author_id=author_id,
plan_id=plan_id,
billing=billing if billing in ("monthly", "annual") else "monthly",
redis=redis,
)
async def confirm_author_checkout(self, author, session_id: str, redis=None) -> dict:
"""Apply a paid Checkout session to the logged-in author and repair grant."""
session = await get_checkout_session(session_id)
if getattr(session, "payment_status", None) != "paid":
raise ValueError("Payment not completed yet. Please wait a moment and refresh.")
meta_author = _stripe_metadata_value(session.metadata, "author_id", "")
if meta_author and meta_author != author.id:
raise ValueError("This payment does not belong to your account.")
addon = _stripe_metadata_value(session.metadata, "addon", "")
if addon == "plan_renew_refill":
result = await self.handle_plan_renew_refill_payment(session_id, redis=redis)
if not result:
raise ValueError("Could not apply renew payment.")
access = await self._access.get_active_for_author(author.id)
fresh = await self._users.get_by_id(author.id)
return {
"ok": True,
"has_active_subscription": access is not None,
"plan": access.plan if access else (fresh.stripe_plan_id if fresh else None),
"stripe_customer_id": fresh.stripe_customer_id if fresh else None,
"renew_refill": True,
}
customer_email = ""
try:
customer_email = _customer_email_from_session(session)
except ValueError:
customer_email = ""
if customer_email and customer_email.lower() != (author.email or "").lower():
cust_id = ""
try:
cust_id = _stripe_resource_id(session.customer)
except ValueError:
cust_id = ""
if not meta_author and cust_id != (author.stripe_customer_id or ""):
raise ValueError("This payment email does not match your account.")
await self.provision_from_checkout_webhook(session_id)
await self.require_active_subscription(author.id)
access = await self._access.get_active_for_author(author.id)
fresh = await self._users.get_by_id(author.id)
return {
"ok": True,
"has_active_subscription": access is not None,
"plan": access.plan if access else (fresh.stripe_plan_id if fresh else None),
"stripe_customer_id": fresh.stripe_customer_id if fresh else None,
}