test(billing): add Stripe E2E test scripts for sandbox validation

- test_stripe_sandbox.py: API-only validation of all 17 products (67 tests)
- stripe_e2e_setup.py: webhook endpoint registration via ngrok
- stripe_e2e_test.py: live webhook tests with real DB verification (67 tests)
- stripe_e2e_checkout_test.py: checkout webhook tests for credit packs,
  sticky boosts, and business plan PDF purchases (40 tests)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Deeman
2026-03-05 10:50:26 +01:00
parent 66c2dfce66
commit 60fa2bc720
4 changed files with 1826 additions and 0 deletions

View File

@@ -0,0 +1,422 @@
"""
Stripe Sandbox Integration Test — verifies all products work end-to-end.
Creates multiple test customers with different personas, tests:
- Checkout session creation for every product
- Subscription creation + cancellation lifecycle
- One-time payment intents
- Price/product consistency
Run: uv run python scripts/test_stripe_sandbox.py
"""
import os
import sys
import time
from dotenv import load_dotenv
load_dotenv()
import stripe
STRIPE_SECRET_KEY = os.getenv("STRIPE_SECRET_KEY", "") or os.getenv("STRIPE_API_PRIVATE_KEY", "")
if not STRIPE_SECRET_KEY:
print("ERROR: STRIPE_SECRET_KEY / STRIPE_API_PRIVATE_KEY not set in .env")
sys.exit(1)
stripe.api_key = STRIPE_SECRET_KEY
stripe.max_network_retries = 2
BASE_URL = os.getenv("BASE_URL", "http://localhost:5000")
# ═══════════════════════════════════════════════════════════
# Expected product catalog — must match setup_stripe.py
# ═══════════════════════════════════════════════════════════
EXPECTED_PRODUCTS = {
"Supplier Growth": {"price_cents": 19900, "billing": "subscription", "interval": "month"},
"Supplier Growth (Yearly)": {"price_cents": 179900, "billing": "subscription", "interval": "year"},
"Supplier Pro": {"price_cents": 49900, "billing": "subscription", "interval": "month"},
"Supplier Pro (Yearly)": {"price_cents": 449900, "billing": "subscription", "interval": "year"},
"Boost: Logo": {"price_cents": 2900, "billing": "subscription", "interval": "month"},
"Boost: Highlight": {"price_cents": 3900, "billing": "subscription", "interval": "month"},
"Boost: Verified Badge": {"price_cents": 4900, "billing": "subscription", "interval": "month"},
"Boost: Custom Card Color": {"price_cents": 5900, "billing": "subscription", "interval": "month"},
"Boost: Sticky Top 1 Week": {"price_cents": 7900, "billing": "one_time"},
"Boost: Sticky Top 1 Month": {"price_cents": 19900, "billing": "one_time"},
"Credit Pack 25": {"price_cents": 9900, "billing": "one_time"},
"Credit Pack 50": {"price_cents": 17900, "billing": "one_time"},
"Credit Pack 100": {"price_cents": 32900, "billing": "one_time"},
"Credit Pack 250": {"price_cents": 74900, "billing": "one_time"},
"Padel Business Plan (PDF)": {"price_cents": 14900, "billing": "one_time"},
"Planner Starter": {"price_cents": 1900, "billing": "subscription", "interval": "month"},
"Planner Pro": {"price_cents": 4900, "billing": "subscription", "interval": "month"},
}
# Test customer personas
TEST_CUSTOMERS = [
{"email": "planner-starter@sandbox.padelnomics.com", "name": "Anna Planner (Starter)"},
{"email": "planner-pro@sandbox.padelnomics.com", "name": "Ben Planner (Pro)"},
{"email": "supplier-growth@sandbox.padelnomics.com", "name": "Carlos Supplier (Growth)"},
{"email": "supplier-pro@sandbox.padelnomics.com", "name": "Diana Supplier (Pro)"},
{"email": "one-time-buyer@sandbox.padelnomics.com", "name": "Eva Buyer (Credits+Boosts)"},
]
passed = 0
failed = 0
errors = []
def ok(msg):
global passed
passed += 1
print(f"{msg}")
def fail(msg):
global failed
failed += 1
errors.append(msg)
print(f"{msg}")
def section(title):
print(f"\n{'' * 60}")
print(f" {title}")
print(f"{'' * 60}")
# ═══════════════════════════════════════════════════════════
# Phase 1: Verify all products and prices exist
# ═══════════════════════════════════════════════════════════
section("Phase 1: Product & Price Verification")
products = list(stripe.Product.list(limit=100, active=True).auto_paging_iter())
product_map = {} # name -> {product_id, price_id, price_amount, price_type, interval}
for product in products:
prices = stripe.Price.list(product=product.id, active=True, limit=1)
if not prices.data:
continue
price = prices.data[0]
product_map[product.name] = {
"product_id": product.id,
"price_id": price.id,
"price_amount": price.unit_amount,
"price_type": price.type,
"interval": price.recurring.interval if price.recurring else None,
}
for name, expected in EXPECTED_PRODUCTS.items():
if name not in product_map:
fail(f"MISSING product: {name}")
continue
actual = product_map[name]
if actual["price_amount"] != expected["price_cents"]:
fail(f"{name}: price {actual['price_amount']} != expected {expected['price_cents']}")
elif expected["billing"] == "subscription" and actual["price_type"] != "recurring":
fail(f"{name}: expected recurring, got {actual['price_type']}")
elif expected["billing"] == "one_time" and actual["price_type"] != "one_time":
fail(f"{name}: expected one_time, got {actual['price_type']}")
elif expected.get("interval") and actual["interval"] != expected["interval"]:
fail(f"{name}: interval {actual['interval']} != expected {expected['interval']}")
else:
ok(f"{name}: €{actual['price_amount']/100:.2f} ({actual['price_type']}"
f"{', ' + actual['interval'] if actual['interval'] else ''})")
extra_products = set(product_map.keys()) - set(EXPECTED_PRODUCTS.keys())
if extra_products:
print(f"\n Extra products in Stripe (not in catalog): {extra_products}")
# ═══════════════════════════════════════════════════════════
# Phase 2: Create test customers (idempotent)
# ═══════════════════════════════════════════════════════════
section("Phase 2: Create Test Customers")
customer_ids = {} # email -> customer_id
for persona in TEST_CUSTOMERS:
existing = stripe.Customer.list(email=persona["email"], limit=1)
if existing.data:
cus = existing.data[0]
ok(f"Reusing: {persona['name']} ({cus.id})")
else:
cus = stripe.Customer.create(
email=persona["email"],
name=persona["name"],
metadata={"test": "true", "persona": persona["name"]},
)
ok(f"Created: {persona['name']} ({cus.id})")
customer_ids[persona["email"]] = cus.id
# ═══════════════════════════════════════════════════════════
# Phase 3: Test Checkout Sessions for every product
# ═══════════════════════════════════════════════════════════
section("Phase 3: Checkout Session Creation (all products)")
success_url = f"{BASE_URL}/billing/success?session_id={{CHECKOUT_SESSION_ID}}"
cancel_url = f"{BASE_URL}/billing/pricing"
# Use the first customer for checkout tests
checkout_customer = customer_ids["planner-starter@sandbox.padelnomics.com"]
for name, info in product_map.items():
if name not in EXPECTED_PRODUCTS:
continue
mode = "subscription" if info["price_type"] == "recurring" else "payment"
try:
session = stripe.checkout.Session.create(
mode=mode,
customer=checkout_customer,
line_items=[{"price": info["price_id"], "quantity": 1}],
metadata={"user_id": "999", "plan": name, "test": "true"},
success_url=success_url,
cancel_url=cancel_url,
)
ok(f"Checkout ({mode}): {name} -> {session.id[:30]}...")
except stripe.StripeError as e:
fail(f"Checkout FAILED for {name}: {e.user_message or str(e)}")
# ═══════════════════════════════════════════════════════════
# Phase 4: Subscription lifecycle tests (per persona)
# ═══════════════════════════════════════════════════════════
section("Phase 4: Subscription Lifecycle Tests")
created_subs = []
# Cache: customer_id -> payment_method_id
_customer_pms = {}
def _ensure_payment_method(cus_id):
"""Create and attach a test Visa card to a customer (cached)."""
if cus_id in _customer_pms:
return _customer_pms[cus_id]
pm = stripe.PaymentMethod.create(type="card", card={"token": "tok_visa"})
stripe.PaymentMethod.attach(pm.id, customer=cus_id)
stripe.Customer.modify(
cus_id,
invoice_settings={"default_payment_method": pm.id},
)
_customer_pms[cus_id] = pm.id
return pm.id
def test_subscription(customer_email, product_name, user_id, extra_metadata=None):
"""Create a subscription, verify it's active, then cancel it."""
cus_id = customer_ids[customer_email]
info = product_map.get(product_name)
if not info:
fail(f"Product not found: {product_name}")
return
metadata = {"user_id": str(user_id), "plan": product_name, "test": "true"}
if extra_metadata:
metadata.update(extra_metadata)
pm_id = _ensure_payment_method(cus_id)
# Create subscription
sub = stripe.Subscription.create(
customer=cus_id,
items=[{"price": info["price_id"]}],
metadata=metadata,
default_payment_method=pm_id,
)
created_subs.append(sub.id)
if sub.status == "active":
ok(f"Sub created: {product_name} for {customer_email} -> {sub.id} (active)")
else:
fail(f"Sub status unexpected: {product_name} -> {sub.status} (expected active)")
# Verify subscription items
items = sub["items"]["data"]
if len(items) == 1 and items[0]["price"]["id"] == info["price_id"]:
ok(f"Sub items correct: price={info['price_id'][:20]}...")
else:
fail(f"Sub items mismatch for {product_name}")
# Cancel at period end
updated = stripe.Subscription.modify(sub.id, cancel_at_period_end=True)
if updated.cancel_at_period_end:
ok(f"Cancel scheduled: {product_name} (cancel_at_period_end=True)")
else:
fail(f"Cancel failed for {product_name}")
# Immediately cancel to clean up
deleted = stripe.Subscription.cancel(sub.id)
if deleted.status == "canceled":
ok(f"Cancelled: {product_name} -> {deleted.status}")
else:
fail(f"Final cancel status: {product_name} -> {deleted.status}")
# Planner Starter
test_subscription(
"planner-starter@sandbox.padelnomics.com", "Planner Starter", user_id=101,
)
# Planner Pro
test_subscription(
"planner-pro@sandbox.padelnomics.com", "Planner Pro", user_id=102,
)
# Supplier Growth (monthly)
test_subscription(
"supplier-growth@sandbox.padelnomics.com", "Supplier Growth", user_id=103,
extra_metadata={"supplier_id": "201"},
)
# Supplier Pro (monthly)
test_subscription(
"supplier-pro@sandbox.padelnomics.com", "Supplier Pro", user_id=104,
extra_metadata={"supplier_id": "202"},
)
# ═══════════════════════════════════════════════════════════
# Phase 5: One-time payment tests
# ═══════════════════════════════════════════════════════════
section("Phase 5: One-Time Payment Tests")
buyer_id = customer_ids["one-time-buyer@sandbox.padelnomics.com"]
buyer_pm = _ensure_payment_method(buyer_id)
ONE_TIME_PRODUCTS = [
"Credit Pack 25",
"Credit Pack 50",
"Credit Pack 100",
"Credit Pack 250",
"Boost: Sticky Top 1 Week",
"Boost: Sticky Top 1 Month",
"Padel Business Plan (PDF)",
]
for product_name in ONE_TIME_PRODUCTS:
info = product_map.get(product_name)
if not info:
fail(f"Product not found: {product_name}")
continue
try:
pi = stripe.PaymentIntent.create(
amount=info["price_amount"],
currency="eur",
customer=buyer_id,
payment_method=buyer_pm,
confirm=True,
automatic_payment_methods={"enabled": True, "allow_redirects": "never"},
metadata={
"user_id": "105",
"supplier_id": "203",
"plan": product_name,
"test": "true",
},
)
if pi.status == "succeeded":
ok(f"Payment: {product_name} -> €{info['price_amount']/100:.2f} ({pi.id[:25]}...)")
else:
fail(f"Payment status: {product_name} -> {pi.status}")
except stripe.StripeError as e:
fail(f"Payment FAILED for {product_name}: {e.user_message or str(e)}")
# ═══════════════════════════════════════════════════════════
# Phase 6: Boost subscription add-ons
# ═══════════════════════════════════════════════════════════
section("Phase 6: Boost Add-on Subscriptions")
BOOST_PRODUCTS = [
"Boost: Logo",
"Boost: Highlight",
"Boost: Verified Badge",
"Boost: Custom Card Color",
]
boost_customer = customer_ids["supplier-pro@sandbox.padelnomics.com"]
boost_pm = _ensure_payment_method(boost_customer)
for product_name in BOOST_PRODUCTS:
info = product_map.get(product_name)
if not info:
fail(f"Product not found: {product_name}")
continue
try:
sub = stripe.Subscription.create(
customer=boost_customer,
items=[{"price": info["price_id"]}],
metadata={
"user_id": "104",
"supplier_id": "202",
"plan": product_name,
"test": "true",
},
default_payment_method=boost_pm,
)
created_subs.append(sub.id)
if sub.status == "active":
ok(f"Boost sub: {product_name} -> €{info['price_amount']/100:.2f}/mo ({sub.id[:25]}...)")
else:
fail(f"Boost sub status: {product_name} -> {sub.status}")
# Clean up
stripe.Subscription.cancel(sub.id)
except stripe.StripeError as e:
fail(f"Boost sub FAILED for {product_name}: {e.user_message or str(e)}")
# ═══════════════════════════════════════════════════════════
# Phase 7: Billing Portal access
# ═══════════════════════════════════════════════════════════
section("Phase 7: Billing Portal")
try:
portal = stripe.billing_portal.Session.create(
customer=checkout_customer,
return_url=f"{BASE_URL}/billing/success",
)
ok(f"Portal URL generated: {portal.url[:50]}...")
except stripe.StripeError as e:
fail(f"Portal creation failed: {e.user_message or str(e)}")
# ═══════════════════════════════════════════════════════════
# Summary
# ═══════════════════════════════════════════════════════════
section("RESULTS")
total = passed + failed
print(f"\n {passed}/{total} passed, {failed} failed\n")
if errors:
print(" Failures:")
for err in errors:
print(f" - {err}")
print()
# Customer summary
print(" Test customers in sandbox:")
for persona in TEST_CUSTOMERS:
cid = customer_ids.get(persona["email"], "?")
print(f" {persona['name']}: {cid}")
print()
sys.exit(1 if failed else 0)