feat(billing): B3 — setup_stripe.py product/price creation script

Mirrors setup_paddle.py structure:
- Creates 17 products + prices in Stripe (same keys, same prices)
- Writes to payment_products table with provider='stripe'
- Registers webhook endpoint at /billing/webhook/stripe
- tax_behavior='exclusive' (price + VAT on top, EU standard)
- Supports --sync flag to re-populate from existing Stripe products

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Deeman
2026-03-03 15:53:38 +01:00
parent 032fe8d86c
commit 7ae8334d7a

View File

@@ -0,0 +1,247 @@
"""
Create or sync Stripe products, prices, and webhook endpoint.
Prerequisites:
- Enable Stripe Tax in your Stripe Dashboard (Settings → Tax)
- Set STRIPE_SECRET_KEY in .env
Commands:
uv run python -m padelnomics.scripts.setup_stripe # create products + webhook
uv run python -m padelnomics.scripts.setup_stripe --sync # re-populate DB from existing Stripe products
"""
import logging
import os
import re
import sqlite3
import sys
from pathlib import Path
import stripe
from dotenv import load_dotenv
logger = logging.getLogger(__name__)
load_dotenv()
STRIPE_SECRET_KEY = os.getenv("STRIPE_SECRET_KEY", "")
DATABASE_PATH = os.getenv("DATABASE_PATH", "data/app.db")
BASE_URL = os.getenv("BASE_URL", "http://localhost:5000")
if not STRIPE_SECRET_KEY:
logging.basicConfig(level=logging.INFO, format="%(levelname)-8s %(message)s")
logger.error("Set STRIPE_SECRET_KEY in .env first")
sys.exit(1)
stripe.api_key = STRIPE_SECRET_KEY
stripe.max_network_retries = 2
# Product definitions — same keys as setup_paddle.py.
# Prices in EUR cents, matching Paddle exactly.
PRODUCTS = [
# Supplier Growth
{
"key": "supplier_growth",
"name": "Supplier Growth",
"price": 19900,
"currency": "eur",
"interval": "month",
"billing_type": "subscription",
},
{
"key": "supplier_growth_yearly",
"name": "Supplier Growth (Yearly)",
"price": 179900,
"currency": "eur",
"interval": "year",
"billing_type": "subscription",
},
# Supplier Pro
{
"key": "supplier_pro",
"name": "Supplier Pro",
"price": 49900,
"currency": "eur",
"interval": "month",
"billing_type": "subscription",
},
{
"key": "supplier_pro_yearly",
"name": "Supplier Pro (Yearly)",
"price": 449900,
"currency": "eur",
"interval": "year",
"billing_type": "subscription",
},
# Boost add-ons (subscriptions)
{"key": "boost_logo", "name": "Boost: Logo", "price": 2900, "currency": "eur", "interval": "month", "billing_type": "subscription"},
{"key": "boost_highlight", "name": "Boost: Highlight", "price": 3900, "currency": "eur", "interval": "month", "billing_type": "subscription"},
{"key": "boost_verified", "name": "Boost: Verified Badge", "price": 4900, "currency": "eur", "interval": "month", "billing_type": "subscription"},
{"key": "boost_card_color", "name": "Boost: Custom Card Color", "price": 5900, "currency": "eur", "interval": "month", "billing_type": "subscription"},
# One-time boosts
{"key": "boost_sticky_week", "name": "Boost: Sticky Top 1 Week", "price": 7900, "currency": "eur", "billing_type": "one_time"},
{"key": "boost_sticky_month", "name": "Boost: Sticky Top 1 Month", "price": 19900, "currency": "eur", "billing_type": "one_time"},
# Credit packs
{"key": "credits_25", "name": "Credit Pack 25", "price": 9900, "currency": "eur", "billing_type": "one_time"},
{"key": "credits_50", "name": "Credit Pack 50", "price": 17900, "currency": "eur", "billing_type": "one_time"},
{"key": "credits_100", "name": "Credit Pack 100", "price": 32900, "currency": "eur", "billing_type": "one_time"},
{"key": "credits_250", "name": "Credit Pack 250", "price": 74900, "currency": "eur", "billing_type": "one_time"},
# PDF product
{"key": "business_plan", "name": "Padel Business Plan (PDF)", "price": 14900, "currency": "eur", "billing_type": "one_time"},
# Planner subscriptions
{"key": "starter", "name": "Planner Starter", "price": 1900, "currency": "eur", "interval": "month", "billing_type": "subscription"},
{"key": "pro", "name": "Planner Pro", "price": 4900, "currency": "eur", "interval": "month", "billing_type": "subscription"},
]
_PRODUCT_BY_NAME = {p["name"]: p for p in PRODUCTS}
def _open_db():
db_path = DATABASE_PATH
if not Path(db_path).exists():
logger.error("Database not found at %s. Run migrations first.", db_path)
sys.exit(1)
conn = sqlite3.connect(db_path)
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=ON")
return conn
def _write_product(conn, key, product_id, price_id, name, price_cents, billing_type):
conn.execute(
"""INSERT OR REPLACE INTO payment_products
(provider, key, provider_product_id, provider_price_id, name, price_cents, currency, billing_type)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
("stripe", key, product_id, price_id, name, price_cents, "EUR", billing_type),
)
def sync(conn):
"""Fetch existing Stripe products and re-populate payment_products table."""
logger.info("Syncing products from Stripe...")
# Fetch all products (auto-paginated, max 100 per page)
products = stripe.Product.list(limit=100, active=True)
matched = 0
for product in products.auto_paging_iter():
spec = _PRODUCT_BY_NAME.get(product.name)
if not spec:
continue
# Get the first active price for this product
prices = stripe.Price.list(product=product.id, active=True, limit=1)
if not prices.data:
logger.warning(" SKIP %s: no active prices on %s", spec["key"], product.id)
continue
price = prices.data[0]
_write_product(
conn, spec["key"], product.id, price.id,
spec["name"], spec["price"], spec["billing_type"],
)
matched += 1
logger.info(" %s: %s / %s", spec["key"], product.id, price.id)
conn.commit()
if matched == 0:
logger.warning("No matching products found in Stripe. Run without --sync first.")
else:
logger.info("%s/%s products synced to DB", matched, len(PRODUCTS))
def create(conn):
"""Create new products and prices in Stripe, write to DB, set up webhook."""
logger.info("Creating products in Stripe...")
for spec in PRODUCTS:
product = stripe.Product.create(
name=spec["name"],
tax_code="txcd_10000000", # General — Tangible Goods (Stripe default)
)
logger.info(" Product: %s -> %s", spec["name"], product.id)
price_params = {
"product": product.id,
"unit_amount": spec["price"],
"currency": spec["currency"],
"tax_behavior": "exclusive", # Price + tax on top (EU standard)
}
if spec["billing_type"] == "subscription":
interval = spec.get("interval", "month")
price_params["recurring"] = {"interval": interval}
price = stripe.Price.create(**price_params)
logger.info(" Price: %s = %s", spec["key"], price.id)
_write_product(
conn, spec["key"], product.id, price.id,
spec["name"], spec["price"], spec["billing_type"],
)
conn.commit()
logger.info("All products written to DB")
# -- Webhook endpoint -------------------------------------------------------
webhook_url = f"{BASE_URL}/billing/webhook/stripe"
enabled_events = [
"checkout.session.completed",
"customer.subscription.updated",
"customer.subscription.deleted",
"invoice.payment_failed",
]
logger.info("Creating webhook endpoint...")
logger.info(" URL: %s", webhook_url)
endpoint = stripe.WebhookEndpoint.create(
url=webhook_url,
enabled_events=enabled_events,
)
webhook_secret = endpoint.secret
logger.info(" ID: %s", endpoint.id)
logger.info(" Secret: %s", webhook_secret)
env_path = Path(".env")
env_vars = {
"STRIPE_WEBHOOK_SECRET": webhook_secret,
"STRIPE_WEBHOOK_ENDPOINT_ID": endpoint.id,
}
if env_path.exists():
env_text = env_path.read_text()
for key, value in env_vars.items():
pattern = rf"^{key}=.*$"
replacement = f"{key}={value}"
if re.search(pattern, env_text, flags=re.MULTILINE):
env_text = re.sub(pattern, replacement, env_text, flags=re.MULTILINE)
else:
env_text = env_text.rstrip("\n") + f"\n{replacement}\n"
env_path.write_text(env_text)
logger.info("STRIPE_WEBHOOK_SECRET and STRIPE_WEBHOOK_ENDPOINT_ID written to .env")
else:
logger.info("Add to .env:")
for key, value in env_vars.items():
logger.info(" %s=%s", key, value)
logger.info("Done. Remember to enable Stripe Tax in your Dashboard (Settings > Tax).")
def main():
conn = _open_db()
try:
if "--sync" in sys.argv:
sync(conn)
else:
create(conn)
finally:
conn.close()
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO, format="%(levelname)-8s %(message)s")
main()