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:
422
scripts/test_stripe_sandbox.py
Normal file
422
scripts/test_stripe_sandbox.py
Normal 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)
|
||||
Reference in New Issue
Block a user