feat(billing): end-to-end Stripe subscription signup with platform enforcement
Move core signup service from marketplace to billing module, add automatic Stripe product/price sync for tiers, create loyalty-specific signup wizard, and enforce that platform is always explicitly known (no silent defaulting to primary/hardcoded ID). Key changes: - New billing SignupService with separated account/store creation steps - Stripe auto-sync on tier create/update (new prices, archive old) - Loyalty signup template (Plan → Account → Store → Payment) - platform_code is now required throughout the signup flow - Pricing/signup pages return 404 if platform not detected - OMS-specific logic (Letzshop claiming) stays in marketplace module - Bootstrap script: scripts/seed/sync_stripe_products.py Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -35,6 +35,141 @@ logger = logging.getLogger(__name__)
|
||||
class AdminSubscriptionService:
|
||||
"""Service for admin subscription management operations."""
|
||||
|
||||
# =========================================================================
|
||||
# Stripe Tier Sync
|
||||
# =========================================================================
|
||||
|
||||
@staticmethod
|
||||
def _sync_tier_to_stripe(db: Session, tier: SubscriptionTier) -> None:
|
||||
"""
|
||||
Sync a tier's Stripe product and prices.
|
||||
|
||||
Creates or verifies the Stripe Product and Price objects, and
|
||||
populates the stripe_product_id, stripe_price_monthly_id, and
|
||||
stripe_price_annual_id fields on the tier.
|
||||
|
||||
Skips gracefully if Stripe is not configured (dev mode).
|
||||
Stripe Prices are immutable — on price changes, new Prices are
|
||||
created and old ones archived.
|
||||
"""
|
||||
from app.core.config import settings
|
||||
|
||||
if not settings.stripe_secret_key:
|
||||
logger.debug(
|
||||
f"Stripe not configured, skipping sync for tier {tier.code}"
|
||||
)
|
||||
return
|
||||
|
||||
import stripe
|
||||
|
||||
stripe.api_key = settings.stripe_secret_key
|
||||
|
||||
# Resolve platform name for product naming
|
||||
platform_name = "Platform"
|
||||
if tier.platform_id:
|
||||
from app.modules.tenancy.services.platform_service import (
|
||||
platform_service,
|
||||
)
|
||||
|
||||
try:
|
||||
platform = platform_service.get_platform_by_id(db, tier.platform_id)
|
||||
platform_name = platform.name
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# --- Product ---
|
||||
if tier.stripe_product_id:
|
||||
# Verify it still exists in Stripe
|
||||
try:
|
||||
stripe.Product.retrieve(tier.stripe_product_id)
|
||||
except stripe.InvalidRequestError:
|
||||
logger.warning(
|
||||
f"Stripe product {tier.stripe_product_id} not found, "
|
||||
f"recreating for tier {tier.code}"
|
||||
)
|
||||
tier.stripe_product_id = None
|
||||
|
||||
if not tier.stripe_product_id:
|
||||
product = stripe.Product.create(
|
||||
name=f"{platform_name} - {tier.name}",
|
||||
metadata={
|
||||
"tier_code": tier.code,
|
||||
"platform_id": str(tier.platform_id or ""),
|
||||
},
|
||||
)
|
||||
tier.stripe_product_id = product.id
|
||||
logger.info(
|
||||
f"Created Stripe product {product.id} for tier {tier.code}"
|
||||
)
|
||||
|
||||
# --- Monthly Price ---
|
||||
if tier.price_monthly_cents:
|
||||
if tier.stripe_price_monthly_id:
|
||||
# Verify price matches; if not, create new one
|
||||
try:
|
||||
existing = stripe.Price.retrieve(tier.stripe_price_monthly_id)
|
||||
if existing.unit_amount != tier.price_monthly_cents:
|
||||
# Price changed — archive old, create new
|
||||
stripe.Price.modify(
|
||||
tier.stripe_price_monthly_id, active=False
|
||||
)
|
||||
tier.stripe_price_monthly_id = None
|
||||
logger.info(
|
||||
f"Archived old monthly price for tier {tier.code}"
|
||||
)
|
||||
except stripe.InvalidRequestError:
|
||||
tier.stripe_price_monthly_id = None
|
||||
|
||||
if not tier.stripe_price_monthly_id:
|
||||
price = stripe.Price.create(
|
||||
product=tier.stripe_product_id,
|
||||
unit_amount=tier.price_monthly_cents,
|
||||
currency="eur",
|
||||
recurring={"interval": "month"},
|
||||
metadata={
|
||||
"tier_code": tier.code,
|
||||
"billing_period": "monthly",
|
||||
},
|
||||
)
|
||||
tier.stripe_price_monthly_id = price.id
|
||||
logger.info(
|
||||
f"Created Stripe monthly price {price.id} "
|
||||
f"for tier {tier.code} ({tier.price_monthly_cents} cents)"
|
||||
)
|
||||
|
||||
# --- Annual Price ---
|
||||
if tier.price_annual_cents:
|
||||
if tier.stripe_price_annual_id:
|
||||
try:
|
||||
existing = stripe.Price.retrieve(tier.stripe_price_annual_id)
|
||||
if existing.unit_amount != tier.price_annual_cents:
|
||||
stripe.Price.modify(
|
||||
tier.stripe_price_annual_id, active=False
|
||||
)
|
||||
tier.stripe_price_annual_id = None
|
||||
logger.info(
|
||||
f"Archived old annual price for tier {tier.code}"
|
||||
)
|
||||
except stripe.InvalidRequestError:
|
||||
tier.stripe_price_annual_id = None
|
||||
|
||||
if not tier.stripe_price_annual_id:
|
||||
price = stripe.Price.create(
|
||||
product=tier.stripe_product_id,
|
||||
unit_amount=tier.price_annual_cents,
|
||||
currency="eur",
|
||||
recurring={"interval": "year"},
|
||||
metadata={
|
||||
"tier_code": tier.code,
|
||||
"billing_period": "annual",
|
||||
},
|
||||
)
|
||||
tier.stripe_price_annual_id = price.id
|
||||
logger.info(
|
||||
f"Created Stripe annual price {price.id} "
|
||||
f"for tier {tier.code} ({tier.price_annual_cents} cents)"
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# Subscription Tiers
|
||||
# =========================================================================
|
||||
@@ -85,6 +220,9 @@ class AdminSubscriptionService:
|
||||
|
||||
tier = SubscriptionTier(**tier_data)
|
||||
db.add(tier)
|
||||
db.flush() # Get tier.id before Stripe sync
|
||||
|
||||
self._sync_tier_to_stripe(db, tier)
|
||||
|
||||
logger.info(f"Created subscription tier: {tier.code}")
|
||||
return tier
|
||||
@@ -95,9 +233,21 @@ class AdminSubscriptionService:
|
||||
"""Update a subscription tier."""
|
||||
tier = self.get_tier_by_code(db, tier_code)
|
||||
|
||||
# Track price changes to know if Stripe sync is needed
|
||||
price_changed = (
|
||||
"price_monthly_cents" in update_data
|
||||
and update_data["price_monthly_cents"] != tier.price_monthly_cents
|
||||
) or (
|
||||
"price_annual_cents" in update_data
|
||||
and update_data["price_annual_cents"] != tier.price_annual_cents
|
||||
)
|
||||
|
||||
for field, value in update_data.items():
|
||||
setattr(tier, field, value)
|
||||
|
||||
if price_changed or not tier.stripe_product_id:
|
||||
self._sync_tier_to_stripe(db, tier)
|
||||
|
||||
logger.info(f"Updated subscription tier: {tier.code}")
|
||||
return tier
|
||||
|
||||
|
||||
Reference in New Issue
Block a user