Files
padelnomics/scripts/test_stripe_sandbox.py
Deeman 60fa2bc720 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>
2026-03-05 10:50:26 +01:00

423 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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)