feat(admin): extend /admin/plan-limits to manage plan_billing fields

Task 30 of self-serve signup Phase 2. Super-admins can now manage Stripe
IDs, display names, prices, and public/archived flags via the existing
admin plan-limits endpoints.

- GET /admin/plan-limits now outer-joins plan_billing and returns
  merged PlanLimitWithBillingResponse rows. Plans without a
  plan_billing row return None for the billing fields.
- PUT /admin/plan-limits accepts the new optional billing fields and
  upserts plan_billing in the same transaction. If no plan_billing
  row exists for the plan and the body includes any billing field, a
  row is created (display_name defaults to plan.capitalize() when
  omitted; display_name is never NULLed out on an existing row).
- After commit, the handler queries account_ids on the affected plan
  and calls BillingService.invalidate_billing_cache(account_ids).
  This is a no-op stub today (logs only) — there's no in-process
  billing cache yet. TODO comment marks the wire-up point.
- 3 new integration tests cover GET-with-billing-present, PUT creating
  a plan_billing row, and the invalidation hook being awaited with a
  list of account_ids.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-06 20:32:09 -04:00
parent 694279f89e
commit d05b475a41
4 changed files with 375 additions and 9 deletions

View File

@@ -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