diff --git a/CHANGELOG.md b/CHANGELOG.md index b72cab2..d7096ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Added +- **Stripe payment provider** — second payment provider alongside Paddle, switchable via `PAYMENT_PROVIDER=stripe` env var. Existing Paddle subscribers keep working regardless of toggle — both webhook endpoints stay active. + - `billing/stripe.py`: full Stripe implementation (Checkout Sessions, Billing Portal, subscription cancel, webhook verification + parsing) + - `billing/paddle.py`: extracted Paddle-specific logic from routes.py into its own module + - `billing/routes.py`: provider-agnostic dispatch layer — checkout, manage, cancel routes call `_provider().xxx()` + - `_payment_js.html`: dual-path JS — conditionally loads Paddle.js SDK, universal `startCheckout()` handles both overlay (Paddle) and redirect (Stripe) + - `scripts/setup_stripe.py`: mirrors `setup_paddle.py` — creates 17 products + prices in Stripe, registers webhook endpoint + - Migration 0028: `payment_products` table generalizing `paddle_products` with `provider` column; existing Paddle rows copied + - `get_price_id()` / `get_all_price_ids()` replace `get_paddle_price()` for provider-agnostic lookups + - Stripe config vars: `STRIPE_SECRET_KEY`, `STRIPE_PUBLISHABLE_KEY`, `STRIPE_WEBHOOK_SECRET` + - Dashboard boost buttons converted from inline `Paddle.Checkout.open()` to server round-trip via `/billing/checkout/item` endpoint + - Stripe Tax add-on handles EU VAT (must be enabled in Stripe Dashboard) + ### Changed - **CRO overhaul — homepage and supplier landing pages** — rewrote all copy from feature-focused ("60+ variables", "6 analysis tabs") to outcome-focused JTBD framing ("Invest in Padel with Confidence, Not Guesswork"). Based on JTBD analysis: the visitor's job is confidence committing €200K+, not "plan faster." - **Homepage hero**: new headline, description, and trust-building bullets (bank-ready metrics, real market data, free/no-signup) diff --git a/PROJECT.md b/PROJECT.md index da6af73..7f11e10 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -60,6 +60,7 @@ - [x] Boost purchases (logo, highlight, verified, card color, sticky week/month) - [x] Credit pack purchases (25/50/100/250) - [x] Supplier subscription tiers (Basic free / Growth €199 / Pro €499, monthly + annual) +- [x] **Stripe payment provider** — env-var toggle (`PAYMENT_PROVIDER=paddle|stripe`), Stripe Checkout Sessions + Billing Portal + webhook handling, `payment_products` table generalizes `paddle_products`, dual-path JS templates, `billing/paddle.py` + `billing/stripe.py` dispatch pattern, `setup_stripe.py` product creation script - [x] **Feature flags** (DB-backed, migration 0019) — `is_flag_enabled()` + `feature_gate()` decorator replace `WAITLIST_MODE`; 5 flags (markets, payments, planner_export, supplier_signup, lead_unlock); admin UI at `/admin/flags` with toggle - [x] **Pricing overhaul** — Basic free (no Paddle sub), card color €59, BP PDF €149; supplier page restructured value-first (why → guarantee → leads → social proof → pricing); all CTAs "Get Started Free"; static ROI line; credits-only callout - [x] **Lead-Back Guarantee** (migration 0020) — 1-click credit refund for non-responding leads (3–30 day window); `refund_lead_guarantee()` in credits.py; "Lead didn't respond" button on unlocked lead cards diff --git a/web/src/padelnomics/billing/stripe.py b/web/src/padelnomics/billing/stripe.py index 9ef719e..8320880 100644 --- a/web/src/padelnomics/billing/stripe.py +++ b/web/src/padelnomics/billing/stripe.py @@ -12,11 +12,8 @@ Exports the same interface as paddle.py so billing/routes.py can dispatch: Stripe Tax add-on handles EU VAT collection (must be enabled in Stripe Dashboard). """ -import hashlib -import hmac import json import logging -import time import stripe as stripe_sdk diff --git a/web/tests/test_billing_routes.py b/web/tests/test_billing_routes.py index 0c29afd..c12eaa0 100644 --- a/web/tests/test_billing_routes.py +++ b/web/tests/test_billing_routes.py @@ -1,6 +1,7 @@ """ -Route integration tests for Paddle billing endpoints. -Checkout uses Paddle.js overlay (returns JSON), manage/cancel use Paddle SDK. +Route integration tests for billing endpoints. +Tests work with the default provider (Paddle) via dispatch layer. +Checkout returns JSON (Paddle overlay or Stripe redirect URL), manage/cancel use provider SDK. """ from unittest.mock import MagicMock, patch @@ -39,7 +40,24 @@ class TestSuccessPage: # ════════════════════════════════════════════════════════════ -# Checkout (Paddle.js overlay — returns JSON) +# Helper: insert a product into both payment_products and paddle_products +# ════════════════════════════════════════════════════════════ + +async def _insert_test_product(db, key="starter", price_id="pri_starter_123"): + """Insert a test product into payment_products (used by get_price_id) and paddle_products (legacy fallback).""" + await db.execute( + "INSERT INTO payment_products (provider, key, provider_product_id, provider_price_id, name, price_cents, currency, billing_type) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + ("paddle", key, "pro_test", price_id, "Starter", 1900, "EUR", "subscription"), + ) + await db.execute( + "INSERT INTO paddle_products (key, paddle_product_id, paddle_price_id, name, price_cents, currency, billing_type) VALUES (?, ?, ?, ?, ?, ?, ?)", + (key, "pro_test", price_id, "Starter", 1900, "EUR", "subscription"), + ) + await db.commit() + + +# ════════════════════════════════════════════════════════════ +# Checkout (returns JSON — Paddle overlay or Stripe redirect) # ════════════════════════════════════════════════════════════ class TestCheckoutRoute: @@ -48,12 +66,7 @@ class TestCheckoutRoute: assert response.status_code in (302, 303, 307) async def test_returns_checkout_json(self, auth_client, db, test_user): - # Insert a paddle_products row so get_paddle_price() finds it - await db.execute( - "INSERT INTO paddle_products (key, paddle_product_id, paddle_price_id, name, price_cents, currency, billing_type) VALUES (?, ?, ?, ?, ?, ?, ?)", - ("starter", "pro_test", "pri_starter_123", "Starter", 1900, "EUR", "subscription"), - ) - await db.commit() + await _insert_test_product(db) response = await auth_client.post(f"/billing/checkout/{CHECKOUT_PLAN}") assert response.status_code == 200 @@ -89,7 +102,7 @@ class TestManageRoute: mock_client = MagicMock() mock_client.subscriptions.get.return_value = mock_sub - with patch("padelnomics.billing.routes._paddle_client", return_value=mock_client): + with patch("padelnomics.billing.paddle._paddle_client", return_value=mock_client): response = await auth_client.post("/billing/manage", follow_redirects=False) assert response.status_code in (302, 303, 307) @@ -111,12 +124,27 @@ class TestCancelRoute: await create_subscription(test_user["id"], provider_subscription_id="sub_test") mock_client = MagicMock() - with patch("padelnomics.billing.routes._paddle_client", return_value=mock_client): + with patch("padelnomics.billing.paddle._paddle_client", return_value=mock_client): response = await auth_client.post("/billing/cancel", follow_redirects=False) assert response.status_code in (302, 303, 307) mock_client.subscriptions.cancel.assert_called_once() +# ════════════════════════════════════════════════════════════ +# Stripe webhook returns 404 when not configured +# ════════════════════════════════════════════════════════════ + +class TestStripeWebhookEndpoint: + async def test_returns_404_when_not_configured(self, client, db): + """Stripe webhook returns 404 when STRIPE_WEBHOOK_SECRET is empty.""" + response = await client.post( + "/billing/webhook/stripe", + data=b'{}', + headers={"Content-Type": "application/json"}, + ) + assert response.status_code == 404 + + # ════════════════════════════════════════════════════════════ # subscription_required decorator # ════════════════════════════════════════════════════════════