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:
247
web/src/padelnomics/scripts/setup_stripe.py
Normal file
247
web/src/padelnomics/scripts/setup_stripe.py
Normal 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()
|
||||
Reference in New Issue
Block a user