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:
2026-02-28 19:16:14 +01:00
parent 2078ce35b2
commit 9684747d08
12 changed files with 1723 additions and 689 deletions

View File

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