feat: self-serve signup Phase 2 (frontend cutover) (#162)
Co-authored-by: Michael Chihlas <michael@resolutionflow.com> Co-committed-by: Michael Chihlas <michael@resolutionflow.com>
This commit was merged in pull request #162.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
"""Single billing service module. Stripe is the only impl — no provider
|
||||
abstraction. Account row is canonical local state; Stripe is canonical
|
||||
remote state; the webhook handler bridges the two."""
|
||||
import logging
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
import stripe
|
||||
@@ -17,8 +18,32 @@ from app.models.subscription import Subscription
|
||||
|
||||
TRIAL_DAYS = 14
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BillingService:
|
||||
@staticmethod
|
||||
async def invalidate_billing_cache(account_ids) -> None:
|
||||
"""No-op stub for future in-process billing cache invalidation.
|
||||
|
||||
Today there is no `app.state.billing_cache` — `BillingService.get_billing_state`
|
||||
always reads fresh from the DB. Call sites that mutate plan/feature data
|
||||
invoke this hook so that wiring is in place when an in-process cache is
|
||||
added later. Until then, this just logs.
|
||||
|
||||
TODO: when an in-process billing cache (e.g. `app.state.billing_cache`)
|
||||
is introduced, evict entries for the given account_ids here.
|
||||
"""
|
||||
try:
|
||||
count = len(list(account_ids))
|
||||
except TypeError:
|
||||
count = -1
|
||||
logger.debug(
|
||||
"BillingService.invalidate_billing_cache called for %d account(s) "
|
||||
"(no-op stub — wire to app.state.billing_cache when added)",
|
||||
count,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def start_trial(db: AsyncSession, account_id) -> Subscription:
|
||||
"""Idempotent. Creates a trialing Subscription on Pro for the account if
|
||||
@@ -105,6 +130,25 @@ class BillingService:
|
||||
)
|
||||
return session.url
|
||||
|
||||
@staticmethod
|
||||
async def open_customer_portal(account: Account) -> str:
|
||||
"""Create a Stripe-hosted Customer Portal session and return the URL.
|
||||
|
||||
Raises RuntimeError if Stripe isn't configured (endpoint maps to 503).
|
||||
Raises ValueError if the account has no stripe_customer_id yet — the
|
||||
user must complete a checkout first (endpoint maps to 400).
|
||||
"""
|
||||
if not settings.stripe_enabled:
|
||||
raise RuntimeError("Stripe not configured")
|
||||
if account.stripe_customer_id is None:
|
||||
raise ValueError("no_stripe_customer")
|
||||
stripe.api_key = settings.STRIPE_SECRET_KEY
|
||||
session = stripe.billing_portal.Session.create(
|
||||
customer=account.stripe_customer_id,
|
||||
return_url=f"{settings.FRONTEND_URL}/account/billing",
|
||||
)
|
||||
return session.url
|
||||
|
||||
@staticmethod
|
||||
async def get_billing_state(db: AsyncSession, account):
|
||||
"""Aggregate Subscription + PlanLimits + PlanBilling + resolved feature
|
||||
@@ -166,28 +210,44 @@ class BillingService:
|
||||
) -> bool:
|
||||
"""Idempotent. Returns True if the event was applied; False if it had
|
||||
already been processed (idempotent ack). The webhook handler returns 200
|
||||
either way."""
|
||||
either way.
|
||||
|
||||
Atomic: the StripeEvent idempotency mark and the handler's state
|
||||
mutations are committed in a single transaction. If the handler raises
|
||||
the entire transaction (idempotency mark + partial mutations) is rolled
|
||||
back, so a Stripe retry will re-run the handler. Without this, a
|
||||
handler that fails mid-flight would leave the StripeEvent row persisted
|
||||
and silently desync subscription state from Stripe.
|
||||
"""
|
||||
db.add(StripeEvent(
|
||||
id=event_id,
|
||||
event_type=event_type,
|
||||
payload_excerpt=_excerpt(payload),
|
||||
))
|
||||
try:
|
||||
db.add(StripeEvent(
|
||||
id=event_id,
|
||||
event_type=event_type,
|
||||
payload_excerpt=_excerpt(payload),
|
||||
))
|
||||
await db.commit()
|
||||
await db.flush()
|
||||
except IntegrityError:
|
||||
# Duplicate event_id — already processed (or in flight). Ack with False.
|
||||
await db.rollback()
|
||||
return False
|
||||
|
||||
if event_type == "checkout.session.completed":
|
||||
await _handle_checkout_completed(db, payload)
|
||||
elif event_type == "customer.subscription.updated":
|
||||
await _handle_subscription_updated(db, payload)
|
||||
elif event_type == "customer.subscription.deleted":
|
||||
await _handle_subscription_deleted(db, payload)
|
||||
elif event_type == "invoice.payment_failed":
|
||||
await _handle_payment_failed(db, payload)
|
||||
elif event_type == "invoice.payment_succeeded":
|
||||
await _handle_payment_succeeded(db, payload)
|
||||
try:
|
||||
if event_type == "checkout.session.completed":
|
||||
await _handle_checkout_completed(db, payload)
|
||||
elif event_type == "customer.subscription.updated":
|
||||
await _handle_subscription_updated(db, payload)
|
||||
elif event_type == "customer.subscription.deleted":
|
||||
await _handle_subscription_deleted(db, payload)
|
||||
elif event_type == "invoice.payment_failed":
|
||||
await _handle_payment_failed(db, payload)
|
||||
elif event_type == "invoice.payment_succeeded":
|
||||
await _handle_payment_succeeded(db, payload)
|
||||
await db.commit()
|
||||
except Exception:
|
||||
# Roll back the StripeEvent insert + any partial handler mutations
|
||||
# so Stripe's retry can re-run cleanly.
|
||||
await db.rollback()
|
||||
raise
|
||||
return True
|
||||
|
||||
|
||||
@@ -238,7 +298,7 @@ async def _handle_checkout_completed(db: AsyncSession, payload: dict):
|
||||
)).scalar_one_or_none()
|
||||
if pb is not None:
|
||||
sub.plan = pb.plan
|
||||
await db.commit()
|
||||
# No commit — apply_subscription_event commits once for the full event.
|
||||
|
||||
|
||||
async def _handle_subscription_updated(db: AsyncSession, payload: dict):
|
||||
@@ -253,7 +313,7 @@ async def _handle_subscription_updated(db: AsyncSession, payload: dict):
|
||||
sub.current_period_end = datetime.fromtimestamp(obj["current_period_end"], tz=timezone.utc)
|
||||
sub.cancel_at_period_end = obj.get("cancel_at_period_end", False)
|
||||
sub.seat_limit = obj["items"]["data"][0]["quantity"]
|
||||
await db.commit()
|
||||
# No commit — apply_subscription_event commits once for the full event.
|
||||
|
||||
|
||||
async def _handle_subscription_deleted(db: AsyncSession, payload: dict):
|
||||
@@ -264,7 +324,7 @@ async def _handle_subscription_deleted(db: AsyncSession, payload: dict):
|
||||
if sub is None:
|
||||
return
|
||||
sub.status = "canceled"
|
||||
await db.commit()
|
||||
# No commit — apply_subscription_event commits once for the full event.
|
||||
|
||||
|
||||
async def _handle_payment_failed(db: AsyncSession, payload: dict):
|
||||
@@ -278,7 +338,7 @@ async def _handle_payment_failed(db: AsyncSession, payload: dict):
|
||||
if sub is None:
|
||||
return
|
||||
sub.status = "past_due"
|
||||
await db.commit()
|
||||
# No commit — apply_subscription_event commits once for the full event.
|
||||
|
||||
|
||||
async def _handle_payment_succeeded(db: AsyncSession, payload: dict):
|
||||
@@ -293,4 +353,4 @@ async def _handle_payment_succeeded(db: AsyncSession, payload: dict):
|
||||
return
|
||||
if sub.status == "past_due":
|
||||
sub.status = "active"
|
||||
await db.commit()
|
||||
# No commit — apply_subscription_event commits once for the full event.
|
||||
|
||||
Reference in New Issue
Block a user