- 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>
423 lines
16 KiB
Python
423 lines
16 KiB
Python
"""
|
||
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)
|