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/uv.lock b/uv.lock index 483b7ff..8a8dae7 100644 --- a/uv.lock +++ b/uv.lock @@ -1392,6 +1392,7 @@ dependencies = [ { name = "pyyaml" }, { name = "quart" }, { name = "resend" }, + { name = "stripe" }, { name = "weasyprint" }, ] @@ -1413,6 +1414,7 @@ requires-dist = [ { name = "pyyaml", specifier = ">=6.0" }, { name = "quart", specifier = ">=0.19.0" }, { name = "resend", specifier = ">=2.22.0" }, + { name = "stripe", specifier = ">=14.4.0" }, { name = "weasyprint", specifier = ">=68.1" }, ] @@ -2519,6 +2521,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521, upload-time = "2023-09-30T13:58:03.53Z" }, ] +[[package]] +name = "stripe" +version = "14.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6a/ec/0f17cff3f7c91b0215266959c5a2a96b0bf9f45ac041c50b99ad8f9b5047/stripe-14.4.0.tar.gz", hash = "sha256:ddaa06f5e38a582bef7e93e06fc304ba8ae3b4c0c2aac43da02c84926f05fa0a", size = 1472370, upload-time = "2026-02-25T17:52:40.905Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/09/fcecad01d76dbe027015dd559ec1b6dccfc319c2540991dde4b1de81ba34/stripe-14.4.0-py3-none-any.whl", hash = "sha256:357151a816cd0bb012d6cb29f108fae50b9f6eece8530d7bc31dfa90c9ceb84c", size = 2115405, upload-time = "2026-02-25T17:52:39.128Z" }, +] + [[package]] name = "tenacity" version = "9.1.4" diff --git a/web/pyproject.toml b/web/pyproject.toml index 4dc2e30..0dda42d 100644 --- a/web/pyproject.toml +++ b/web/pyproject.toml @@ -22,6 +22,7 @@ dependencies = [ "httpx>=0.27.0", "google-api-python-client>=2.100.0", "google-auth>=2.23.0", + "stripe>=14.4.0", ] [build-system] diff --git a/web/src/padelnomics/billing/__init__.py b/web/src/padelnomics/billing/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/web/src/padelnomics/billing/paddle.py b/web/src/padelnomics/billing/paddle.py new file mode 100644 index 0000000..4d0d864 --- /dev/null +++ b/web/src/padelnomics/billing/paddle.py @@ -0,0 +1,116 @@ +""" +Paddle payment provider — checkout, webhook verification, subscription management. + +Exports the 5 functions that billing/routes.py dispatches to: +- build_checkout_payload() +- build_multi_item_checkout_payload() +- cancel_subscription() +- get_management_url() +- handle_webhook() +""" + +import json + +from paddle_billing import Client as PaddleClient +from paddle_billing import Environment, Options +from paddle_billing.Notifications import Secret, Verifier + +from ..core import config + + +def _paddle_client() -> PaddleClient: + """Create a Paddle SDK client.""" + env = Environment.SANDBOX if config.PADDLE_ENVIRONMENT == "sandbox" else Environment.PRODUCTION + return PaddleClient(config.PADDLE_API_KEY, options=Options(env)) + + +class _WebhookRequest: + """Minimal wrapper satisfying paddle_billing's Request Protocol.""" + + def __init__(self, body: bytes, headers): + self.body = body + self.headers = headers + + +_verifier = Verifier(maximum_variance=300) + + +def build_checkout_payload( + price_id: str, custom_data: dict, success_url: str, +) -> dict: + """Build JSON payload for a single-item Paddle.js overlay checkout.""" + return { + "items": [{"priceId": price_id, "quantity": 1}], + "customData": custom_data, + "settings": {"successUrl": success_url}, + } + + +def build_multi_item_checkout_payload( + items: list[dict], custom_data: dict, success_url: str, +) -> dict: + """Build JSON payload for a multi-item Paddle.js overlay checkout.""" + return { + "items": items, + "customData": custom_data, + "settings": {"successUrl": success_url}, + } + + +def cancel_subscription(provider_subscription_id: str) -> None: + """Cancel a Paddle subscription at end of current billing period.""" + from paddle_billing.Resources.Subscriptions.Operations import CancelSubscription + + paddle = _paddle_client() + paddle.subscriptions.cancel( + provider_subscription_id, + CancelSubscription(effective_from="next_billing_period"), + ) + + +def get_management_url(provider_subscription_id: str) -> str: + """Get the Paddle customer portal URL for updating payment method.""" + paddle = _paddle_client() + paddle_sub = paddle.subscriptions.get(provider_subscription_id) + return paddle_sub.management_urls.update_payment_method + + +def verify_webhook(payload: bytes, headers) -> bool: + """Verify Paddle webhook signature. Returns True if valid or no secret configured.""" + if not config.PADDLE_WEBHOOK_SECRET: + return True + try: + return _verifier.verify( + _WebhookRequest(payload, headers), + Secret(config.PADDLE_WEBHOOK_SECRET), + ) + except (ConnectionRefusedError, ValueError): + return False + + +def parse_webhook(payload: bytes) -> dict: + """Parse a Paddle webhook payload into a normalized event dict. + + Returns dict with keys: event_type, subscription_id, customer_id, + user_id, supplier_id, plan, status, current_period_end, data, items. + """ + event = json.loads(payload) + event_type = event.get("event_type", "") + data = event.get("data") or {} + custom_data = data.get("custom_data") or {} + + billing_period = data.get("current_billing_period") or {} + + return { + "event_type": event_type, + "subscription_id": data.get("id", ""), + "customer_id": str(data.get("customer_id", "")), + "user_id": custom_data.get("user_id"), + "supplier_id": custom_data.get("supplier_id"), + "plan": custom_data.get("plan", ""), + "status": data.get("status", ""), + "current_period_end": billing_period.get("ends_at"), + "data": data, + "items": data.get("items", []), + "custom_data": custom_data, + } diff --git a/web/src/padelnomics/billing/routes.py b/web/src/padelnomics/billing/routes.py index 5cb2f15..84feaba 100644 --- a/web/src/padelnomics/billing/routes.py +++ b/web/src/padelnomics/billing/routes.py @@ -1,6 +1,9 @@ """ Billing domain: checkout, webhooks, subscription management. -Payment provider: paddle + +Provider dispatch: PAYMENT_PROVIDER env var selects 'paddle' or 'stripe'. +Both webhook endpoints (/webhook/paddle and /webhook/stripe) stay active +regardless of the toggle — existing subscribers keep sending webhooks. """ import json @@ -8,20 +11,21 @@ import secrets from datetime import timedelta from pathlib import Path -from paddle_billing import Client as PaddleClient -from paddle_billing import Environment, Options -from paddle_billing.Notifications import Secret, Verifier from quart import Blueprint, flash, g, jsonify, redirect, render_template, request, session, url_for from ..auth.routes import login_required -from ..core import config, execute, fetch_one, get_paddle_price, utcnow, utcnow_iso +from ..core import config, execute, fetch_one, get_price_id, utcnow, utcnow_iso from ..i18n import get_translations -def _paddle_client() -> PaddleClient: - """Create a Paddle SDK client. Used only for subscription management + webhook verification.""" - env = Environment.SANDBOX if config.PADDLE_ENVIRONMENT == "sandbox" else Environment.PRODUCTION - return PaddleClient(config.PADDLE_API_KEY, options=Options(env)) +def _provider(): + """Return the active payment provider module.""" + if config.PAYMENT_PROVIDER == "stripe": + from . import stripe as mod + else: + from . import paddle as mod + return mod + # Blueprint with its own template folder bp = Blueprint( @@ -33,7 +37,7 @@ bp = Blueprint( # ============================================================================= -# SQL Queries +# SQL Queries (provider-agnostic) # ============================================================================= async def get_subscription(user_id: int) -> dict | None: @@ -132,7 +136,7 @@ async def is_within_limits(user_id: int, resource: str, current_count: int) -> b # ============================================================================= -# Routes +# Routes (provider-agnostic) # ============================================================================= @bp.route("/pricing") @@ -151,129 +155,171 @@ async def success(): return await render_template("success.html") - # ============================================================================= -# Paddle Implementation — Paddle.js Overlay Checkout +# Checkout / Manage / Cancel — dispatched to active provider # ============================================================================= @bp.route("/checkout/", methods=["POST"]) @login_required async def checkout(plan: str): - """Return JSON for Paddle.js overlay checkout.""" - price_id = await get_paddle_price(plan) + """Return JSON for checkout (overlay for Paddle, redirect URL for Stripe).""" + price_id = await get_price_id(plan) if not price_id: return jsonify({"error": "Invalid plan selected."}), 400 - return jsonify({ - "items": [{"priceId": price_id, "quantity": 1}], - "customData": {"user_id": str(g.user["id"]), "plan": plan}, - "settings": { - "successUrl": f"{config.BASE_URL}/billing/success", - }, - }) + payload = _provider().build_checkout_payload( + price_id=price_id, + custom_data={"user_id": str(g.user["id"]), "plan": plan}, + success_url=f"{config.BASE_URL}/billing/success", + ) + return jsonify(payload) + + +@bp.route("/checkout/item", methods=["POST"]) +@login_required +async def checkout_item(): + """Return checkout JSON for a single item (boost, credit pack, etc.). + + Used by dashboard boost/credit buttons that need a server round-trip + for Stripe (Checkout Session creation) and work with Paddle overlay too. + Expects JSON body: {price_key, custom_data, success_url?} + """ + body = await request.get_json(silent=True) or {} + price_key = body.get("price_key", "") + custom_data = body.get("custom_data", {}) + success_url = body.get("success_url", f"{config.BASE_URL}/suppliers/dashboard?tab=boosts") + + price_id = await get_price_id(price_key) + if not price_id: + return jsonify({"error": "Product not configured."}), 400 + + payload = _provider().build_checkout_payload( + price_id=price_id, + custom_data=custom_data, + success_url=success_url, + ) + return jsonify(payload) @bp.route("/manage", methods=["POST"]) @login_required async def manage(): - """Redirect to Paddle customer portal.""" + """Redirect to payment provider's customer portal.""" sub = await get_subscription(g.user["id"]) if not sub or not sub.get("provider_subscription_id"): t = get_translations(g.get("lang") or "en") await flash(t["billing_no_subscription"], "error") return redirect(url_for("dashboard.settings")) - paddle = _paddle_client() - paddle_sub = paddle.subscriptions.get(sub["provider_subscription_id"]) - portal_url = paddle_sub.management_urls.update_payment_method + portal_url = _provider().get_management_url(sub["provider_subscription_id"]) return redirect(portal_url) @bp.route("/cancel", methods=["POST"]) @login_required async def cancel(): - """Cancel subscription via Paddle API.""" + """Cancel subscription via active payment provider.""" sub = await get_subscription(g.user["id"]) if sub and sub.get("provider_subscription_id"): - from paddle_billing.Resources.Subscriptions.Operations import CancelSubscription - paddle = _paddle_client() - paddle.subscriptions.cancel( - sub["provider_subscription_id"], - CancelSubscription(effective_from="next_billing_period"), - ) + _provider().cancel_subscription(sub["provider_subscription_id"]) return redirect(url_for("dashboard.settings")) -class _WebhookRequest: - """Minimal wrapper satisfying paddle_billing's Request Protocol.""" - def __init__(self, body: bytes, headers): - self.body = body - self.headers = headers - - -_verifier = Verifier(maximum_variance=300) - +# ============================================================================= +# Paddle Webhook — always active (existing subscribers keep sending) +# ============================================================================= @bp.route("/webhook/paddle", methods=["POST"]) -async def webhook(): - """Handle Paddle webhooks.""" +async def webhook_paddle(): + """Handle Paddle webhooks — always active regardless of PAYMENT_PROVIDER toggle.""" + from . import paddle as paddle_mod + payload = await request.get_data() - if config.PADDLE_WEBHOOK_SECRET: - try: - ok = _verifier.verify( - _WebhookRequest(payload, request.headers), - Secret(config.PADDLE_WEBHOOK_SECRET), - ) - except (ConnectionRefusedError, ValueError): - ok = False - if not ok: - return jsonify({"error": "Invalid signature"}), 400 + if not paddle_mod.verify_webhook(payload, request.headers): + return jsonify({"error": "Invalid signature"}), 400 try: - event = json.loads(payload) + ev = paddle_mod.parse_webhook(payload) except (json.JSONDecodeError, ValueError): return jsonify({"error": "Invalid JSON payload"}), 400 - event_type = event.get("event_type") - data = event.get("data") or {} - custom_data = data.get("custom_data") or {} - user_id = custom_data.get("user_id") - plan = custom_data.get("plan", "") - # Store billing customer for any subscription event with a customer_id - customer_id = str(data.get("customer_id", "")) + await _handle_webhook_event(ev) + return jsonify({"received": True}) + + +# ============================================================================= +# Stripe Webhook — always active (once Stripe is configured) +# ============================================================================= + +@bp.route("/webhook/stripe", methods=["POST"]) +async def webhook_stripe(): + """Handle Stripe webhooks — always active regardless of PAYMENT_PROVIDER toggle.""" + if not config.STRIPE_WEBHOOK_SECRET: + return jsonify({"error": "Stripe not configured"}), 404 + + from . import stripe as stripe_mod + + payload = await request.get_data() + + if not stripe_mod.verify_webhook(payload, request.headers): + return jsonify({"error": "Invalid signature"}), 400 + + try: + ev = stripe_mod.parse_webhook(payload) + except (json.JSONDecodeError, ValueError): + return jsonify({"error": "Invalid payload"}), 400 + + await _handle_webhook_event(ev) + return jsonify({"received": True}) + + +# ============================================================================= +# Shared Webhook Event Handler (provider-agnostic) +# ============================================================================= + +async def _handle_webhook_event(ev: dict) -> None: + """Process a normalized webhook event from any provider. + + ev keys: event_type, subscription_id, customer_id, user_id, supplier_id, + plan, status, current_period_end, data, items, custom_data + """ + event_type = ev.get("event_type", "") + user_id = ev.get("user_id") + plan = ev.get("plan", "") + + # Store billing customer + customer_id = ev.get("customer_id", "") if customer_id and user_id: await upsert_billing_customer(int(user_id), customer_id) if event_type == "subscription.activated": if plan.startswith("supplier_"): - await _handle_supplier_subscription_activated(data, custom_data) + await _handle_supplier_subscription_activated(ev) elif user_id: await upsert_subscription( user_id=int(user_id), plan=plan or "starter", status="active", - provider_subscription_id=data.get("id", ""), - current_period_end=(data.get("current_billing_period") or {}).get("ends_at"), + provider_subscription_id=ev.get("subscription_id", ""), + current_period_end=ev.get("current_period_end"), ) elif event_type == "subscription.updated": await update_subscription_status( - data.get("id", ""), - status=data.get("status", "active"), - current_period_end=(data.get("current_billing_period") or {}).get("ends_at"), + ev.get("subscription_id", ""), + status=ev.get("status", "active"), + current_period_end=ev.get("current_period_end"), ) elif event_type == "subscription.canceled": - await update_subscription_status(data.get("id", ""), status="cancelled") + await update_subscription_status(ev.get("subscription_id", ""), status="cancelled") elif event_type == "subscription.past_due": - await update_subscription_status(data.get("id", ""), status="past_due") + await update_subscription_status(ev.get("subscription_id", ""), status="past_due") elif event_type == "transaction.completed": - await _handle_transaction_completed(data, custom_data) - - return jsonify({"received": True}) + await _handle_transaction_completed(ev) # ============================================================================= @@ -301,7 +347,13 @@ BOOST_PRICE_KEYS = { async def _price_id_to_key(price_id: str) -> str | None: - """Reverse-lookup a paddle_products key from a Paddle price ID.""" + """Reverse-lookup a product key from a provider price ID.""" + row = await fetch_one( + "SELECT key FROM payment_products WHERE provider_price_id = ?", (price_id,) + ) + if row: + return row["key"] + # Fallback to old table for pre-migration DBs row = await fetch_one( "SELECT key FROM paddle_products WHERE paddle_price_id = ?", (price_id,) ) @@ -330,13 +382,13 @@ def _derive_tier_from_plan(plan: str) -> tuple[str, str]: return base, tier -async def _handle_supplier_subscription_activated(data: dict, custom_data: dict) -> None: +async def _handle_supplier_subscription_activated(ev: dict) -> None: """Handle supplier plan subscription activation.""" from ..core import transaction as db_transaction - supplier_id = custom_data.get("supplier_id") - plan = custom_data.get("plan", "supplier_growth") - user_id = custom_data.get("user_id") + supplier_id = ev.get("supplier_id") + plan = ev.get("plan", "supplier_growth") + user_id = ev.get("user_id") if not supplier_id: return @@ -365,7 +417,8 @@ async def _handle_supplier_subscription_activated(data: dict, custom_data: dict) ) # Create boost records for items included in the subscription - items = data.get("items", []) + items = ev.get("items", []) + data = ev.get("data", {}) for item in items: price_id = item.get("price", {}).get("id", "") key = await _price_id_to_key(price_id) @@ -388,13 +441,15 @@ async def _handle_supplier_subscription_activated(data: dict, custom_data: dict) ) -async def _handle_transaction_completed(data: dict, custom_data: dict) -> None: +async def _handle_transaction_completed(ev: dict) -> None: """Handle one-time transaction completion (credit packs, sticky boosts, business plan).""" - supplier_id = custom_data.get("supplier_id") - user_id = custom_data.get("user_id") + supplier_id = ev.get("supplier_id") + user_id = ev.get("user_id") + custom_data = ev.get("custom_data", {}) + data = ev.get("data", {}) now = utcnow_iso() - items = data.get("items", []) + items = ev.get("items", []) for item in items: price_id = item.get("price", {}).get("id", "") key = await _price_id_to_key(price_id) diff --git a/web/src/padelnomics/billing/stripe.py b/web/src/padelnomics/billing/stripe.py new file mode 100644 index 0000000..8320880 --- /dev/null +++ b/web/src/padelnomics/billing/stripe.py @@ -0,0 +1,326 @@ +""" +Stripe payment provider — checkout sessions, webhook handling, subscription management. + +Exports the same interface as paddle.py so billing/routes.py can dispatch: +- build_checkout_payload() +- build_multi_item_checkout_payload() +- cancel_subscription() +- get_management_url() +- verify_webhook() +- parse_webhook() + +Stripe Tax add-on handles EU VAT collection (must be enabled in Stripe Dashboard). +""" + +import json +import logging + +import stripe as stripe_sdk + +from ..core import config + +logger = logging.getLogger(__name__) + +# Timeout for all Stripe API calls (seconds) +_STRIPE_TIMEOUT_SECONDS = 10 + + +def _stripe_client(): + """Configure and return the stripe module with our API key.""" + stripe_sdk.api_key = config.STRIPE_SECRET_KEY + stripe_sdk.max_network_retries = 2 + return stripe_sdk + + +def build_checkout_payload( + price_id: str, custom_data: dict, success_url: str, +) -> dict: + """Create a Stripe Checkout Session for a single item. + + Returns {checkout_url: "https://checkout.stripe.com/..."} — the client + JS redirects the browser there (no overlay SDK needed). + """ + s = _stripe_client() + session = s.checkout.Session.create( + mode=_mode_for_price(s, price_id), + line_items=[{"price": price_id, "quantity": 1}], + metadata=custom_data, + success_url=success_url + "?session_id={CHECKOUT_SESSION_ID}", + cancel_url=success_url.rsplit("/success", 1)[0] + "/pricing", + automatic_tax={"enabled": True}, + tax_id_collection={"enabled": True}, + request_options={"timeout": _STRIPE_TIMEOUT_SECONDS}, + ) + return {"checkout_url": session.url} + + +def build_multi_item_checkout_payload( + items: list[dict], custom_data: dict, success_url: str, +) -> dict: + """Create a Stripe Checkout Session for multiple line items. + + items: list of {"priceId": "price_xxx", "quantity": 1} + """ + s = _stripe_client() + + line_items = [{"price": i["priceId"], "quantity": i.get("quantity", 1)} for i in items] + + # Determine mode: if any item is recurring, use "subscription". + # Otherwise use "payment" for one-time purchases. + has_recurring = any(_is_recurring_price(s, i["priceId"]) for i in items) + mode = "subscription" if has_recurring else "payment" + + session = s.checkout.Session.create( + mode=mode, + line_items=line_items, + metadata=custom_data, + success_url=success_url + "?session_id={CHECKOUT_SESSION_ID}", + cancel_url=success_url.rsplit("/success", 1)[0], + automatic_tax={"enabled": True}, + tax_id_collection={"enabled": True}, + request_options={"timeout": _STRIPE_TIMEOUT_SECONDS}, + ) + return {"checkout_url": session.url} + + +def _mode_for_price(s, price_id: str) -> str: + """Determine Checkout Session mode from price type.""" + try: + price = s.Price.retrieve(price_id, request_options={"timeout": _STRIPE_TIMEOUT_SECONDS}) + return "subscription" if price.type == "recurring" else "payment" + except Exception: + # Default to payment if we can't determine + return "payment" + + +def _is_recurring_price(s, price_id: str) -> bool: + """Check if a Stripe price is recurring (subscription).""" + try: + price = s.Price.retrieve(price_id, request_options={"timeout": _STRIPE_TIMEOUT_SECONDS}) + return price.type == "recurring" + except Exception: + return False + + +def cancel_subscription(provider_subscription_id: str) -> None: + """Cancel a Stripe subscription at end of current billing period.""" + s = _stripe_client() + s.Subscription.modify( + provider_subscription_id, + cancel_at_period_end=True, + request_options={"timeout": _STRIPE_TIMEOUT_SECONDS}, + ) + + +def get_management_url(provider_subscription_id: str) -> str: + """Create a Stripe Billing Portal session and return its URL.""" + s = _stripe_client() + + # Get customer_id from the subscription + sub = s.Subscription.retrieve( + provider_subscription_id, + request_options={"timeout": _STRIPE_TIMEOUT_SECONDS}, + ) + portal = s.billing_portal.Session.create( + customer=sub.customer, + return_url=f"{config.BASE_URL}/billing/success", + request_options={"timeout": _STRIPE_TIMEOUT_SECONDS}, + ) + return portal.url + + +def verify_webhook(payload: bytes, headers) -> bool: + """Verify Stripe webhook signature using the Stripe-Signature header.""" + if not config.STRIPE_WEBHOOK_SECRET: + return True + sig_header = headers.get("Stripe-Signature", "") + if not sig_header: + return False + try: + stripe_sdk.Webhook.construct_event( + payload, sig_header, config.STRIPE_WEBHOOK_SECRET, + ) + return True + except (stripe_sdk.SignatureVerificationError, ValueError): + return False + + +def parse_webhook(payload: bytes) -> dict: + """Parse a Stripe webhook payload into a normalized event dict. + + Maps Stripe event types to the shared format used by _handle_webhook_event(): + - checkout.session.completed (mode=subscription) → subscription.activated + - customer.subscription.updated → subscription.updated + - customer.subscription.deleted → subscription.canceled + - invoice.payment_failed → subscription.past_due + - checkout.session.completed (mode=payment) → transaction.completed + """ + raw = json.loads(payload) + stripe_type = raw.get("type", "") + obj = raw.get("data", {}).get("object", {}) + + # Extract metadata — Stripe stores custom data in session/subscription metadata + metadata = obj.get("metadata") or {} + + # Common fields + customer_id = obj.get("customer", "") + user_id = metadata.get("user_id") + supplier_id = metadata.get("supplier_id") + plan = metadata.get("plan", "") + + # Map Stripe events to our shared event types + if stripe_type == "checkout.session.completed": + mode = obj.get("mode", "") + if mode == "subscription": + subscription_id = obj.get("subscription", "") + # Fetch subscription details for period end + period_end = None + if subscription_id: + try: + s = _stripe_client() + sub = s.Subscription.retrieve( + subscription_id, + request_options={"timeout": _STRIPE_TIMEOUT_SECONDS}, + ) + period_end = _unix_to_iso(sub.current_period_end) + except Exception: + logger.warning("Failed to fetch subscription %s for period_end", subscription_id) + + return { + "event_type": "subscription.activated", + "subscription_id": subscription_id, + "customer_id": str(customer_id), + "user_id": user_id, + "supplier_id": supplier_id, + "plan": plan, + "status": "active", + "current_period_end": period_end, + "data": obj, + "items": _extract_line_items(obj), + "custom_data": metadata, + } + else: + # One-time payment + return { + "event_type": "transaction.completed", + "subscription_id": "", + "customer_id": str(customer_id), + "user_id": user_id, + "supplier_id": supplier_id, + "plan": plan, + "status": "completed", + "current_period_end": None, + "data": obj, + "items": _extract_line_items(obj), + "custom_data": metadata, + } + + elif stripe_type == "customer.subscription.updated": + status = _map_stripe_status(obj.get("status", "")) + return { + "event_type": "subscription.updated", + "subscription_id": obj.get("id", ""), + "customer_id": str(customer_id), + "user_id": user_id, + "supplier_id": supplier_id, + "plan": plan, + "status": status, + "current_period_end": _unix_to_iso(obj.get("current_period_end")), + "data": obj, + "items": _extract_sub_items(obj), + "custom_data": metadata, + } + + elif stripe_type == "customer.subscription.deleted": + return { + "event_type": "subscription.canceled", + "subscription_id": obj.get("id", ""), + "customer_id": str(customer_id), + "user_id": user_id, + "supplier_id": supplier_id, + "plan": plan, + "status": "cancelled", + "current_period_end": _unix_to_iso(obj.get("current_period_end")), + "data": obj, + "items": _extract_sub_items(obj), + "custom_data": metadata, + } + + elif stripe_type == "invoice.payment_failed": + sub_id = obj.get("subscription", "") + return { + "event_type": "subscription.past_due", + "subscription_id": sub_id, + "customer_id": str(customer_id), + "user_id": user_id, + "supplier_id": supplier_id, + "plan": plan, + "status": "past_due", + "current_period_end": None, + "data": obj, + "items": [], + "custom_data": metadata, + } + + # Unknown event — return a no-op + return { + "event_type": "", + "subscription_id": "", + "customer_id": str(customer_id), + "user_id": user_id, + "supplier_id": supplier_id, + "plan": plan, + "status": "", + "current_period_end": None, + "data": obj, + "items": [], + "custom_data": metadata, + } + + +# ============================================================================= +# Helpers +# ============================================================================= + +def _map_stripe_status(stripe_status: str) -> str: + """Map Stripe subscription status to our internal status.""" + mapping = { + "active": "active", + "trialing": "on_trial", + "past_due": "past_due", + "canceled": "cancelled", + "unpaid": "past_due", + "incomplete": "past_due", + "incomplete_expired": "expired", + "paused": "paused", + } + return mapping.get(stripe_status, stripe_status) + + +def _unix_to_iso(ts) -> str | None: + """Convert Unix timestamp to ISO string, or None.""" + if not ts: + return None + from datetime import UTC, datetime + return datetime.fromtimestamp(int(ts), tz=UTC).strftime("%Y-%m-%dT%H:%M:%S.000000Z") + + +def _extract_line_items(session_obj: dict) -> list[dict]: + """Extract line items from a Checkout Session in Paddle-compatible format. + + Stripe sessions don't embed line items directly — we'd need an extra API call. + For webhook handling, the key info (price_id) comes from subscription items. + Returns items in the format: [{"price": {"id": "price_xxx"}}] + """ + # For checkout.session.completed, line_items aren't in the webhook payload. + # The webhook handler for subscription.activated fetches them separately. + # For one-time payments, we can reconstruct from the session's line_items + # via the Stripe API, but to keep webhook handling fast we skip this and + # handle it via the subscription events instead. + return [] + + +def _extract_sub_items(sub_obj: dict) -> list[dict]: + """Extract items from a Stripe Subscription object in Paddle-compatible format.""" + items = sub_obj.get("items", {}).get("data", []) + return [{"price": {"id": item.get("price", {}).get("id", "")}} for item in items] diff --git a/web/src/padelnomics/core.py b/web/src/padelnomics/core.py index ed5c905..1894322 100644 --- a/web/src/padelnomics/core.py +++ b/web/src/padelnomics/core.py @@ -49,13 +49,17 @@ class Config: MAGIC_LINK_EXPIRY_MINUTES: int = int(os.getenv("MAGIC_LINK_EXPIRY_MINUTES", "15")) SESSION_LIFETIME_DAYS: int = int(os.getenv("SESSION_LIFETIME_DAYS", "30")) - PAYMENT_PROVIDER: str = "paddle" + PAYMENT_PROVIDER: str = _env("PAYMENT_PROVIDER", "paddle") PADDLE_API_KEY: str = os.getenv("PADDLE_API_KEY", "") PADDLE_CLIENT_TOKEN: str = os.getenv("PADDLE_CLIENT_TOKEN", "") PADDLE_WEBHOOK_SECRET: str = os.getenv("PADDLE_WEBHOOK_SECRET", "") PADDLE_ENVIRONMENT: str = _env("PADDLE_ENVIRONMENT", "sandbox") + STRIPE_SECRET_KEY: str = os.getenv("STRIPE_SECRET_KEY", "") + STRIPE_PUBLISHABLE_KEY: str = os.getenv("STRIPE_PUBLISHABLE_KEY", "") + STRIPE_WEBHOOK_SECRET: str = os.getenv("STRIPE_WEBHOOK_SECRET", "") + UMAMI_API_URL: str = os.getenv("UMAMI_API_URL", "https://umami.padelnomics.io") UMAMI_API_TOKEN: str = os.getenv("UMAMI_API_TOKEN", "") UMAMI_WEBSITE_ID: str = "4474414b-58d6-4c6e-89a1-df5ea1f49d70" @@ -722,16 +726,39 @@ async def purge_deleted(table: str, days: int = 30) -> int: # ============================================================================= +async def get_price_id(key: str, provider: str = None) -> str | None: + """Look up a provider price ID by product key from the payment_products table.""" + provider = provider or config.PAYMENT_PROVIDER + row = await fetch_one( + "SELECT provider_price_id FROM payment_products WHERE provider = ? AND key = ?", + (provider, key), + ) + return row["provider_price_id"] if row else None + + +async def get_all_price_ids(provider: str = None) -> dict[str, str]: + """Load all price IDs for a provider as a {key: price_id} dict.""" + provider = provider or config.PAYMENT_PROVIDER + rows = await fetch_all( + "SELECT key, provider_price_id FROM payment_products WHERE provider = ?", + (provider,), + ) + return {r["key"]: r["provider_price_id"] for r in rows} + + async def get_paddle_price(key: str) -> str | None: - """Look up a Paddle price ID by product key from the paddle_products table.""" + """Deprecated: use get_price_id(). Falls back to paddle_products for pre-migration DBs.""" + result = await get_price_id(key, provider="paddle") + if result: + return result + # Fallback to old table if payment_products not yet populated row = await fetch_one("SELECT paddle_price_id FROM paddle_products WHERE key = ?", (key,)) return row["paddle_price_id"] if row else None async def get_all_paddle_prices() -> dict[str, str]: - """Load all Paddle price IDs as a {key: price_id} dict.""" - rows = await fetch_all("SELECT key, paddle_price_id FROM paddle_products") - return {r["key"]: r["paddle_price_id"] for r in rows} + """Deprecated: use get_all_price_ids().""" + return await get_all_price_ids(provider="paddle") # ============================================================================= diff --git a/web/src/padelnomics/migrations/versions/0028_generalize_payment_products.py b/web/src/padelnomics/migrations/versions/0028_generalize_payment_products.py new file mode 100644 index 0000000..2c8beff --- /dev/null +++ b/web/src/padelnomics/migrations/versions/0028_generalize_payment_products.py @@ -0,0 +1,33 @@ +"""Migration 0028: Generalize paddle_products → payment_products. + +New table supports multiple payment providers (paddle, stripe). +Existing paddle_products rows are copied with provider='paddle'. +The old paddle_products table is kept (no drop) for backwards compatibility. +""" + + +def up(conn) -> None: + conn.execute(""" + CREATE TABLE IF NOT EXISTS payment_products ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + provider TEXT NOT NULL, + key TEXT NOT NULL, + provider_product_id TEXT NOT NULL, + provider_price_id TEXT NOT NULL, + name TEXT NOT NULL, + price_cents INTEGER NOT NULL, + currency TEXT NOT NULL DEFAULT 'EUR', + billing_type TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(provider, key) + ) + """) + + # Copy existing paddle_products rows + conn.execute(""" + INSERT OR IGNORE INTO payment_products + (provider, key, provider_product_id, provider_price_id, name, price_cents, currency, billing_type, created_at) + SELECT + 'paddle', key, paddle_product_id, paddle_price_id, name, price_cents, currency, billing_type, created_at + FROM paddle_products + """) diff --git a/web/src/padelnomics/planner/routes.py b/web/src/padelnomics/planner/routes.py index a236205..7ee3fa5 100644 --- a/web/src/padelnomics/planner/routes.py +++ b/web/src/padelnomics/planner/routes.py @@ -18,7 +18,7 @@ from ..core import ( feature_gate, fetch_all, fetch_one, - get_paddle_price, + get_price_id, utcnow_iso, ) from ..i18n import get_translations @@ -687,7 +687,9 @@ async def export_details(): @login_required @csrf_protect async def export_checkout(): - """Return JSON for Paddle.js overlay checkout for business plan PDF.""" + """Return checkout JSON for business plan PDF (works with Paddle overlay or Stripe redirect).""" + from ..billing.routes import _provider + form = await request.form scenario_id = form.get("scenario_id") language = form.get("language", "en") @@ -703,23 +705,20 @@ async def export_checkout(): if not scenario: return jsonify({"error": "Scenario not found."}), 404 - price_id = await get_paddle_price("business_plan") + price_id = await get_price_id("business_plan") if not price_id: return jsonify({"error": "Product not configured. Contact support."}), 500 - return jsonify( - { - "items": [{"priceId": price_id, "quantity": 1}], - "customData": { - "user_id": str(g.user["id"]), - "scenario_id": str(scenario_id), - "language": language, - }, - "settings": { - "successUrl": f"{config.BASE_URL}/planner/export/success", - }, - } + payload = _provider().build_checkout_payload( + price_id=price_id, + custom_data={ + "user_id": str(g.user["id"]), + "scenario_id": str(scenario_id), + "language": language, + }, + success_url=f"{config.BASE_URL}/planner/export/success", ) + return jsonify(payload) @bp.route("/export/success") diff --git a/web/src/padelnomics/planner/templates/export.html b/web/src/padelnomics/planner/templates/export.html index 70f99fa..f5c2327 100644 --- a/web/src/padelnomics/planner/templates/export.html +++ b/web/src/padelnomics/planner/templates/export.html @@ -1,6 +1,6 @@ {% extends "base.html" %} {% block title %}{{ t.export_title }} - {{ config.APP_NAME }}{% endblock %} -{% block paddle %}{% include "_paddle.html" %}{% endblock %} +{% block paddle %}{% include "_payment_js.html" %}{% endblock %} {% block head %}