merge: Stripe payment provider (dispatch-by-config alongside Paddle)
This commit is contained in:
13
CHANGELOG.md
13
CHANGELOG.md
@@ -6,6 +6,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
### 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."
|
- **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)
|
- **Homepage hero**: new headline, description, and trust-building bullets (bank-ready metrics, real market data, free/no-signup)
|
||||||
|
|||||||
@@ -60,6 +60,7 @@
|
|||||||
- [x] Boost purchases (logo, highlight, verified, card color, sticky week/month)
|
- [x] Boost purchases (logo, highlight, verified, card color, sticky week/month)
|
||||||
- [x] Credit pack purchases (25/50/100/250)
|
- [x] Credit pack purchases (25/50/100/250)
|
||||||
- [x] Supplier subscription tiers (Basic free / Growth €199 / Pro €499, monthly + annual)
|
- [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] **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] **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
|
- [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
|
||||||
|
|||||||
15
uv.lock
generated
15
uv.lock
generated
@@ -1392,6 +1392,7 @@ dependencies = [
|
|||||||
{ name = "pyyaml" },
|
{ name = "pyyaml" },
|
||||||
{ name = "quart" },
|
{ name = "quart" },
|
||||||
{ name = "resend" },
|
{ name = "resend" },
|
||||||
|
{ name = "stripe" },
|
||||||
{ name = "weasyprint" },
|
{ name = "weasyprint" },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1413,6 +1414,7 @@ requires-dist = [
|
|||||||
{ name = "pyyaml", specifier = ">=6.0" },
|
{ name = "pyyaml", specifier = ">=6.0" },
|
||||||
{ name = "quart", specifier = ">=0.19.0" },
|
{ name = "quart", specifier = ">=0.19.0" },
|
||||||
{ name = "resend", specifier = ">=2.22.0" },
|
{ name = "resend", specifier = ">=2.22.0" },
|
||||||
|
{ name = "stripe", specifier = ">=14.4.0" },
|
||||||
{ name = "weasyprint", specifier = ">=68.1" },
|
{ 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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "tenacity"
|
name = "tenacity"
|
||||||
version = "9.1.4"
|
version = "9.1.4"
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ dependencies = [
|
|||||||
"httpx>=0.27.0",
|
"httpx>=0.27.0",
|
||||||
"google-api-python-client>=2.100.0",
|
"google-api-python-client>=2.100.0",
|
||||||
"google-auth>=2.23.0",
|
"google-auth>=2.23.0",
|
||||||
|
"stripe>=14.4.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
|
|||||||
0
web/src/padelnomics/billing/__init__.py
Normal file
0
web/src/padelnomics/billing/__init__.py
Normal file
116
web/src/padelnomics/billing/paddle.py
Normal file
116
web/src/padelnomics/billing/paddle.py
Normal file
@@ -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,
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
"""
|
"""
|
||||||
Billing domain: checkout, webhooks, subscription management.
|
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
|
import json
|
||||||
@@ -8,20 +11,21 @@ import secrets
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from pathlib import Path
|
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 quart import Blueprint, flash, g, jsonify, redirect, render_template, request, session, url_for
|
||||||
|
|
||||||
from ..auth.routes import login_required
|
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
|
from ..i18n import get_translations
|
||||||
|
|
||||||
|
|
||||||
def _paddle_client() -> PaddleClient:
|
def _provider():
|
||||||
"""Create a Paddle SDK client. Used only for subscription management + webhook verification."""
|
"""Return the active payment provider module."""
|
||||||
env = Environment.SANDBOX if config.PADDLE_ENVIRONMENT == "sandbox" else Environment.PRODUCTION
|
if config.PAYMENT_PROVIDER == "stripe":
|
||||||
return PaddleClient(config.PADDLE_API_KEY, options=Options(env))
|
from . import stripe as mod
|
||||||
|
else:
|
||||||
|
from . import paddle as mod
|
||||||
|
return mod
|
||||||
|
|
||||||
|
|
||||||
# Blueprint with its own template folder
|
# Blueprint with its own template folder
|
||||||
bp = Blueprint(
|
bp = Blueprint(
|
||||||
@@ -33,7 +37,7 @@ bp = Blueprint(
|
|||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# SQL Queries
|
# SQL Queries (provider-agnostic)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
async def get_subscription(user_id: int) -> dict | None:
|
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")
|
@bp.route("/pricing")
|
||||||
@@ -151,129 +155,171 @@ async def success():
|
|||||||
return await render_template("success.html")
|
return await render_template("success.html")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Paddle Implementation — Paddle.js Overlay Checkout
|
# Checkout / Manage / Cancel — dispatched to active provider
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@bp.route("/checkout/<plan>", methods=["POST"])
|
@bp.route("/checkout/<plan>", methods=["POST"])
|
||||||
@login_required
|
@login_required
|
||||||
async def checkout(plan: str):
|
async def checkout(plan: str):
|
||||||
"""Return JSON for Paddle.js overlay checkout."""
|
"""Return JSON for checkout (overlay for Paddle, redirect URL for Stripe)."""
|
||||||
price_id = await get_paddle_price(plan)
|
price_id = await get_price_id(plan)
|
||||||
if not price_id:
|
if not price_id:
|
||||||
return jsonify({"error": "Invalid plan selected."}), 400
|
return jsonify({"error": "Invalid plan selected."}), 400
|
||||||
|
|
||||||
return jsonify({
|
payload = _provider().build_checkout_payload(
|
||||||
"items": [{"priceId": price_id, "quantity": 1}],
|
price_id=price_id,
|
||||||
"customData": {"user_id": str(g.user["id"]), "plan": plan},
|
custom_data={"user_id": str(g.user["id"]), "plan": plan},
|
||||||
"settings": {
|
success_url=f"{config.BASE_URL}/billing/success",
|
||||||
"successUrl": 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"])
|
@bp.route("/manage", methods=["POST"])
|
||||||
@login_required
|
@login_required
|
||||||
async def manage():
|
async def manage():
|
||||||
"""Redirect to Paddle customer portal."""
|
"""Redirect to payment provider's customer portal."""
|
||||||
sub = await get_subscription(g.user["id"])
|
sub = await get_subscription(g.user["id"])
|
||||||
if not sub or not sub.get("provider_subscription_id"):
|
if not sub or not sub.get("provider_subscription_id"):
|
||||||
t = get_translations(g.get("lang") or "en")
|
t = get_translations(g.get("lang") or "en")
|
||||||
await flash(t["billing_no_subscription"], "error")
|
await flash(t["billing_no_subscription"], "error")
|
||||||
return redirect(url_for("dashboard.settings"))
|
return redirect(url_for("dashboard.settings"))
|
||||||
|
|
||||||
paddle = _paddle_client()
|
portal_url = _provider().get_management_url(sub["provider_subscription_id"])
|
||||||
paddle_sub = paddle.subscriptions.get(sub["provider_subscription_id"])
|
|
||||||
portal_url = paddle_sub.management_urls.update_payment_method
|
|
||||||
return redirect(portal_url)
|
return redirect(portal_url)
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/cancel", methods=["POST"])
|
@bp.route("/cancel", methods=["POST"])
|
||||||
@login_required
|
@login_required
|
||||||
async def cancel():
|
async def cancel():
|
||||||
"""Cancel subscription via Paddle API."""
|
"""Cancel subscription via active payment provider."""
|
||||||
sub = await get_subscription(g.user["id"])
|
sub = await get_subscription(g.user["id"])
|
||||||
if sub and sub.get("provider_subscription_id"):
|
if sub and sub.get("provider_subscription_id"):
|
||||||
from paddle_billing.Resources.Subscriptions.Operations import CancelSubscription
|
_provider().cancel_subscription(sub["provider_subscription_id"])
|
||||||
paddle = _paddle_client()
|
|
||||||
paddle.subscriptions.cancel(
|
|
||||||
sub["provider_subscription_id"],
|
|
||||||
CancelSubscription(effective_from="next_billing_period"),
|
|
||||||
)
|
|
||||||
return redirect(url_for("dashboard.settings"))
|
return redirect(url_for("dashboard.settings"))
|
||||||
|
|
||||||
|
|
||||||
class _WebhookRequest:
|
# =============================================================================
|
||||||
"""Minimal wrapper satisfying paddle_billing's Request Protocol."""
|
# Paddle Webhook — always active (existing subscribers keep sending)
|
||||||
def __init__(self, body: bytes, headers):
|
# =============================================================================
|
||||||
self.body = body
|
|
||||||
self.headers = headers
|
|
||||||
|
|
||||||
|
|
||||||
_verifier = Verifier(maximum_variance=300)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/webhook/paddle", methods=["POST"])
|
@bp.route("/webhook/paddle", methods=["POST"])
|
||||||
async def webhook():
|
async def webhook_paddle():
|
||||||
"""Handle Paddle webhooks."""
|
"""Handle Paddle webhooks — always active regardless of PAYMENT_PROVIDER toggle."""
|
||||||
|
from . import paddle as paddle_mod
|
||||||
|
|
||||||
payload = await request.get_data()
|
payload = await request.get_data()
|
||||||
|
|
||||||
if config.PADDLE_WEBHOOK_SECRET:
|
if not paddle_mod.verify_webhook(payload, request.headers):
|
||||||
try:
|
return jsonify({"error": "Invalid signature"}), 400
|
||||||
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
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
event = json.loads(payload)
|
ev = paddle_mod.parse_webhook(payload)
|
||||||
except (json.JSONDecodeError, ValueError):
|
except (json.JSONDecodeError, ValueError):
|
||||||
return jsonify({"error": "Invalid JSON payload"}), 400
|
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
|
await _handle_webhook_event(ev)
|
||||||
customer_id = str(data.get("customer_id", ""))
|
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:
|
if customer_id and user_id:
|
||||||
await upsert_billing_customer(int(user_id), customer_id)
|
await upsert_billing_customer(int(user_id), customer_id)
|
||||||
|
|
||||||
if event_type == "subscription.activated":
|
if event_type == "subscription.activated":
|
||||||
if plan.startswith("supplier_"):
|
if plan.startswith("supplier_"):
|
||||||
await _handle_supplier_subscription_activated(data, custom_data)
|
await _handle_supplier_subscription_activated(ev)
|
||||||
elif user_id:
|
elif user_id:
|
||||||
await upsert_subscription(
|
await upsert_subscription(
|
||||||
user_id=int(user_id),
|
user_id=int(user_id),
|
||||||
plan=plan or "starter",
|
plan=plan or "starter",
|
||||||
status="active",
|
status="active",
|
||||||
provider_subscription_id=data.get("id", ""),
|
provider_subscription_id=ev.get("subscription_id", ""),
|
||||||
current_period_end=(data.get("current_billing_period") or {}).get("ends_at"),
|
current_period_end=ev.get("current_period_end"),
|
||||||
)
|
)
|
||||||
|
|
||||||
elif event_type == "subscription.updated":
|
elif event_type == "subscription.updated":
|
||||||
await update_subscription_status(
|
await update_subscription_status(
|
||||||
data.get("id", ""),
|
ev.get("subscription_id", ""),
|
||||||
status=data.get("status", "active"),
|
status=ev.get("status", "active"),
|
||||||
current_period_end=(data.get("current_billing_period") or {}).get("ends_at"),
|
current_period_end=ev.get("current_period_end"),
|
||||||
)
|
)
|
||||||
|
|
||||||
elif event_type == "subscription.canceled":
|
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":
|
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":
|
elif event_type == "transaction.completed":
|
||||||
await _handle_transaction_completed(data, custom_data)
|
await _handle_transaction_completed(ev)
|
||||||
|
|
||||||
return jsonify({"received": True})
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -301,7 +347,13 @@ BOOST_PRICE_KEYS = {
|
|||||||
|
|
||||||
|
|
||||||
async def _price_id_to_key(price_id: str) -> str | None:
|
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(
|
row = await fetch_one(
|
||||||
"SELECT key FROM paddle_products WHERE paddle_price_id = ?", (price_id,)
|
"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
|
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."""
|
"""Handle supplier plan subscription activation."""
|
||||||
from ..core import transaction as db_transaction
|
from ..core import transaction as db_transaction
|
||||||
|
|
||||||
supplier_id = custom_data.get("supplier_id")
|
supplier_id = ev.get("supplier_id")
|
||||||
plan = custom_data.get("plan", "supplier_growth")
|
plan = ev.get("plan", "supplier_growth")
|
||||||
user_id = custom_data.get("user_id")
|
user_id = ev.get("user_id")
|
||||||
|
|
||||||
if not supplier_id:
|
if not supplier_id:
|
||||||
return
|
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
|
# 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:
|
for item in items:
|
||||||
price_id = item.get("price", {}).get("id", "")
|
price_id = item.get("price", {}).get("id", "")
|
||||||
key = await _price_id_to_key(price_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)."""
|
"""Handle one-time transaction completion (credit packs, sticky boosts, business plan)."""
|
||||||
supplier_id = custom_data.get("supplier_id")
|
supplier_id = ev.get("supplier_id")
|
||||||
user_id = custom_data.get("user_id")
|
user_id = ev.get("user_id")
|
||||||
|
custom_data = ev.get("custom_data", {})
|
||||||
|
data = ev.get("data", {})
|
||||||
now = utcnow_iso()
|
now = utcnow_iso()
|
||||||
|
|
||||||
items = data.get("items", [])
|
items = ev.get("items", [])
|
||||||
for item in items:
|
for item in items:
|
||||||
price_id = item.get("price", {}).get("id", "")
|
price_id = item.get("price", {}).get("id", "")
|
||||||
key = await _price_id_to_key(price_id)
|
key = await _price_id_to_key(price_id)
|
||||||
|
|||||||
326
web/src/padelnomics/billing/stripe.py
Normal file
326
web/src/padelnomics/billing/stripe.py
Normal file
@@ -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]
|
||||||
@@ -49,13 +49,17 @@ class Config:
|
|||||||
MAGIC_LINK_EXPIRY_MINUTES: int = int(os.getenv("MAGIC_LINK_EXPIRY_MINUTES", "15"))
|
MAGIC_LINK_EXPIRY_MINUTES: int = int(os.getenv("MAGIC_LINK_EXPIRY_MINUTES", "15"))
|
||||||
SESSION_LIFETIME_DAYS: int = int(os.getenv("SESSION_LIFETIME_DAYS", "30"))
|
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_API_KEY: str = os.getenv("PADDLE_API_KEY", "")
|
||||||
PADDLE_CLIENT_TOKEN: str = os.getenv("PADDLE_CLIENT_TOKEN", "")
|
PADDLE_CLIENT_TOKEN: str = os.getenv("PADDLE_CLIENT_TOKEN", "")
|
||||||
PADDLE_WEBHOOK_SECRET: str = os.getenv("PADDLE_WEBHOOK_SECRET", "")
|
PADDLE_WEBHOOK_SECRET: str = os.getenv("PADDLE_WEBHOOK_SECRET", "")
|
||||||
PADDLE_ENVIRONMENT: str = _env("PADDLE_ENVIRONMENT", "sandbox")
|
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_URL: str = os.getenv("UMAMI_API_URL", "https://umami.padelnomics.io")
|
||||||
UMAMI_API_TOKEN: str = os.getenv("UMAMI_API_TOKEN", "")
|
UMAMI_API_TOKEN: str = os.getenv("UMAMI_API_TOKEN", "")
|
||||||
UMAMI_WEBSITE_ID: str = "4474414b-58d6-4c6e-89a1-df5ea1f49d70"
|
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:
|
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,))
|
row = await fetch_one("SELECT paddle_price_id FROM paddle_products WHERE key = ?", (key,))
|
||||||
return row["paddle_price_id"] if row else None
|
return row["paddle_price_id"] if row else None
|
||||||
|
|
||||||
|
|
||||||
async def get_all_paddle_prices() -> dict[str, str]:
|
async def get_all_paddle_prices() -> dict[str, str]:
|
||||||
"""Load all Paddle price IDs as a {key: price_id} dict."""
|
"""Deprecated: use get_all_price_ids()."""
|
||||||
rows = await fetch_all("SELECT key, paddle_price_id FROM paddle_products")
|
return await get_all_price_ids(provider="paddle")
|
||||||
return {r["key"]: r["paddle_price_id"] for r in rows}
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -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
|
||||||
|
""")
|
||||||
@@ -18,7 +18,7 @@ from ..core import (
|
|||||||
feature_gate,
|
feature_gate,
|
||||||
fetch_all,
|
fetch_all,
|
||||||
fetch_one,
|
fetch_one,
|
||||||
get_paddle_price,
|
get_price_id,
|
||||||
utcnow_iso,
|
utcnow_iso,
|
||||||
)
|
)
|
||||||
from ..i18n import get_translations
|
from ..i18n import get_translations
|
||||||
@@ -687,7 +687,9 @@ async def export_details():
|
|||||||
@login_required
|
@login_required
|
||||||
@csrf_protect
|
@csrf_protect
|
||||||
async def export_checkout():
|
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
|
form = await request.form
|
||||||
scenario_id = form.get("scenario_id")
|
scenario_id = form.get("scenario_id")
|
||||||
language = form.get("language", "en")
|
language = form.get("language", "en")
|
||||||
@@ -703,23 +705,20 @@ async def export_checkout():
|
|||||||
if not scenario:
|
if not scenario:
|
||||||
return jsonify({"error": "Scenario not found."}), 404
|
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:
|
if not price_id:
|
||||||
return jsonify({"error": "Product not configured. Contact support."}), 500
|
return jsonify({"error": "Product not configured. Contact support."}), 500
|
||||||
|
|
||||||
return jsonify(
|
payload = _provider().build_checkout_payload(
|
||||||
{
|
price_id=price_id,
|
||||||
"items": [{"priceId": price_id, "quantity": 1}],
|
custom_data={
|
||||||
"customData": {
|
"user_id": str(g.user["id"]),
|
||||||
"user_id": str(g.user["id"]),
|
"scenario_id": str(scenario_id),
|
||||||
"scenario_id": str(scenario_id),
|
"language": language,
|
||||||
"language": language,
|
},
|
||||||
},
|
success_url=f"{config.BASE_URL}/planner/export/success",
|
||||||
"settings": {
|
|
||||||
"successUrl": f"{config.BASE_URL}/planner/export/success",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
return jsonify(payload)
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/export/success")
|
@bp.route("/export/success")
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}{{ t.export_title }} - {{ config.APP_NAME }}{% endblock %}
|
{% block title %}{{ t.export_title }} - {{ config.APP_NAME }}{% endblock %}
|
||||||
{% block paddle %}{% include "_paddle.html" %}{% endblock %}
|
{% block paddle %}{% include "_payment_js.html" %}{% endblock %}
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
<style>
|
<style>
|
||||||
@@ -133,11 +133,7 @@ document.getElementById('export-form').addEventListener('submit', async function
|
|||||||
btn.textContent = '{{ t.export_btn }}';
|
btn.textContent = '{{ t.export_btn }}';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Paddle.Checkout.open({
|
startCheckout(data);
|
||||||
items: data.items,
|
|
||||||
customData: data.customData,
|
|
||||||
settings: data.settings,
|
|
||||||
});
|
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
btn.textContent = '{{ t.export_btn }}';
|
btn.textContent = '{{ t.export_btn }}';
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
247
web/src/padelnomics/scripts/setup_stripe.py
Normal file
247
web/src/padelnomics/scripts/setup_stripe.py
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
"""
|
||||||
|
Create or sync Stripe products, prices, and webhook endpoint.
|
||||||
|
|
||||||
|
Prerequisites:
|
||||||
|
- Enable Stripe Tax in your Stripe Dashboard (Settings → Tax)
|
||||||
|
- Set STRIPE_SECRET_KEY in .env
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
uv run python -m padelnomics.scripts.setup_stripe # create products + webhook
|
||||||
|
uv run python -m padelnomics.scripts.setup_stripe --sync # re-populate DB from existing Stripe products
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import stripe
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
STRIPE_SECRET_KEY = os.getenv("STRIPE_SECRET_KEY", "")
|
||||||
|
DATABASE_PATH = os.getenv("DATABASE_PATH", "data/app.db")
|
||||||
|
BASE_URL = os.getenv("BASE_URL", "http://localhost:5000")
|
||||||
|
|
||||||
|
if not STRIPE_SECRET_KEY:
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(levelname)-8s %(message)s")
|
||||||
|
logger.error("Set STRIPE_SECRET_KEY in .env first")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
stripe.api_key = STRIPE_SECRET_KEY
|
||||||
|
stripe.max_network_retries = 2
|
||||||
|
|
||||||
|
# Product definitions — same keys as setup_paddle.py.
|
||||||
|
# Prices in EUR cents, matching Paddle exactly.
|
||||||
|
PRODUCTS = [
|
||||||
|
# Supplier Growth
|
||||||
|
{
|
||||||
|
"key": "supplier_growth",
|
||||||
|
"name": "Supplier Growth",
|
||||||
|
"price": 19900,
|
||||||
|
"currency": "eur",
|
||||||
|
"interval": "month",
|
||||||
|
"billing_type": "subscription",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "supplier_growth_yearly",
|
||||||
|
"name": "Supplier Growth (Yearly)",
|
||||||
|
"price": 179900,
|
||||||
|
"currency": "eur",
|
||||||
|
"interval": "year",
|
||||||
|
"billing_type": "subscription",
|
||||||
|
},
|
||||||
|
# Supplier Pro
|
||||||
|
{
|
||||||
|
"key": "supplier_pro",
|
||||||
|
"name": "Supplier Pro",
|
||||||
|
"price": 49900,
|
||||||
|
"currency": "eur",
|
||||||
|
"interval": "month",
|
||||||
|
"billing_type": "subscription",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "supplier_pro_yearly",
|
||||||
|
"name": "Supplier Pro (Yearly)",
|
||||||
|
"price": 449900,
|
||||||
|
"currency": "eur",
|
||||||
|
"interval": "year",
|
||||||
|
"billing_type": "subscription",
|
||||||
|
},
|
||||||
|
# Boost add-ons (subscriptions)
|
||||||
|
{"key": "boost_logo", "name": "Boost: Logo", "price": 2900, "currency": "eur", "interval": "month", "billing_type": "subscription"},
|
||||||
|
{"key": "boost_highlight", "name": "Boost: Highlight", "price": 3900, "currency": "eur", "interval": "month", "billing_type": "subscription"},
|
||||||
|
{"key": "boost_verified", "name": "Boost: Verified Badge", "price": 4900, "currency": "eur", "interval": "month", "billing_type": "subscription"},
|
||||||
|
{"key": "boost_card_color", "name": "Boost: Custom Card Color", "price": 5900, "currency": "eur", "interval": "month", "billing_type": "subscription"},
|
||||||
|
# One-time boosts
|
||||||
|
{"key": "boost_sticky_week", "name": "Boost: Sticky Top 1 Week", "price": 7900, "currency": "eur", "billing_type": "one_time"},
|
||||||
|
{"key": "boost_sticky_month", "name": "Boost: Sticky Top 1 Month", "price": 19900, "currency": "eur", "billing_type": "one_time"},
|
||||||
|
# Credit packs
|
||||||
|
{"key": "credits_25", "name": "Credit Pack 25", "price": 9900, "currency": "eur", "billing_type": "one_time"},
|
||||||
|
{"key": "credits_50", "name": "Credit Pack 50", "price": 17900, "currency": "eur", "billing_type": "one_time"},
|
||||||
|
{"key": "credits_100", "name": "Credit Pack 100", "price": 32900, "currency": "eur", "billing_type": "one_time"},
|
||||||
|
{"key": "credits_250", "name": "Credit Pack 250", "price": 74900, "currency": "eur", "billing_type": "one_time"},
|
||||||
|
# PDF product
|
||||||
|
{"key": "business_plan", "name": "Padel Business Plan (PDF)", "price": 14900, "currency": "eur", "billing_type": "one_time"},
|
||||||
|
# Planner subscriptions
|
||||||
|
{"key": "starter", "name": "Planner Starter", "price": 1900, "currency": "eur", "interval": "month", "billing_type": "subscription"},
|
||||||
|
{"key": "pro", "name": "Planner Pro", "price": 4900, "currency": "eur", "interval": "month", "billing_type": "subscription"},
|
||||||
|
]
|
||||||
|
|
||||||
|
_PRODUCT_BY_NAME = {p["name"]: p for p in PRODUCTS}
|
||||||
|
|
||||||
|
|
||||||
|
def _open_db():
|
||||||
|
db_path = DATABASE_PATH
|
||||||
|
if not Path(db_path).exists():
|
||||||
|
logger.error("Database not found at %s. Run migrations first.", db_path)
|
||||||
|
sys.exit(1)
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
|
conn.execute("PRAGMA foreign_keys=ON")
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def _write_product(conn, key, product_id, price_id, name, price_cents, billing_type):
|
||||||
|
conn.execute(
|
||||||
|
"""INSERT OR REPLACE INTO payment_products
|
||||||
|
(provider, key, provider_product_id, provider_price_id, name, price_cents, currency, billing_type)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||||
|
("stripe", key, product_id, price_id, name, price_cents, "EUR", billing_type),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def sync(conn):
|
||||||
|
"""Fetch existing Stripe products and re-populate payment_products table."""
|
||||||
|
logger.info("Syncing products from Stripe...")
|
||||||
|
|
||||||
|
# Fetch all products (auto-paginated, max 100 per page)
|
||||||
|
products = stripe.Product.list(limit=100, active=True)
|
||||||
|
matched = 0
|
||||||
|
|
||||||
|
for product in products.auto_paging_iter():
|
||||||
|
spec = _PRODUCT_BY_NAME.get(product.name)
|
||||||
|
if not spec:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Get the first active price for this product
|
||||||
|
prices = stripe.Price.list(product=product.id, active=True, limit=1)
|
||||||
|
if not prices.data:
|
||||||
|
logger.warning(" SKIP %s: no active prices on %s", spec["key"], product.id)
|
||||||
|
continue
|
||||||
|
|
||||||
|
price = prices.data[0]
|
||||||
|
_write_product(
|
||||||
|
conn, spec["key"], product.id, price.id,
|
||||||
|
spec["name"], spec["price"], spec["billing_type"],
|
||||||
|
)
|
||||||
|
matched += 1
|
||||||
|
logger.info(" %s: %s / %s", spec["key"], product.id, price.id)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
if matched == 0:
|
||||||
|
logger.warning("No matching products found in Stripe. Run without --sync first.")
|
||||||
|
else:
|
||||||
|
logger.info("%s/%s products synced to DB", matched, len(PRODUCTS))
|
||||||
|
|
||||||
|
|
||||||
|
def create(conn):
|
||||||
|
"""Create new products and prices in Stripe, write to DB, set up webhook."""
|
||||||
|
logger.info("Creating products in Stripe...")
|
||||||
|
|
||||||
|
for spec in PRODUCTS:
|
||||||
|
product = stripe.Product.create(
|
||||||
|
name=spec["name"],
|
||||||
|
tax_code="txcd_10000000", # General — Tangible Goods (Stripe default)
|
||||||
|
)
|
||||||
|
logger.info(" Product: %s -> %s", spec["name"], product.id)
|
||||||
|
|
||||||
|
price_params = {
|
||||||
|
"product": product.id,
|
||||||
|
"unit_amount": spec["price"],
|
||||||
|
"currency": spec["currency"],
|
||||||
|
"tax_behavior": "exclusive", # Price + tax on top (EU standard)
|
||||||
|
}
|
||||||
|
|
||||||
|
if spec["billing_type"] == "subscription":
|
||||||
|
interval = spec.get("interval", "month")
|
||||||
|
price_params["recurring"] = {"interval": interval}
|
||||||
|
|
||||||
|
price = stripe.Price.create(**price_params)
|
||||||
|
logger.info(" Price: %s = %s", spec["key"], price.id)
|
||||||
|
|
||||||
|
_write_product(
|
||||||
|
conn, spec["key"], product.id, price.id,
|
||||||
|
spec["name"], spec["price"], spec["billing_type"],
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
logger.info("All products written to DB")
|
||||||
|
|
||||||
|
# -- Webhook endpoint -------------------------------------------------------
|
||||||
|
|
||||||
|
webhook_url = f"{BASE_URL}/billing/webhook/stripe"
|
||||||
|
enabled_events = [
|
||||||
|
"checkout.session.completed",
|
||||||
|
"customer.subscription.updated",
|
||||||
|
"customer.subscription.deleted",
|
||||||
|
"invoice.payment_failed",
|
||||||
|
]
|
||||||
|
|
||||||
|
logger.info("Creating webhook endpoint...")
|
||||||
|
logger.info(" URL: %s", webhook_url)
|
||||||
|
|
||||||
|
endpoint = stripe.WebhookEndpoint.create(
|
||||||
|
url=webhook_url,
|
||||||
|
enabled_events=enabled_events,
|
||||||
|
)
|
||||||
|
|
||||||
|
webhook_secret = endpoint.secret
|
||||||
|
logger.info(" ID: %s", endpoint.id)
|
||||||
|
logger.info(" Secret: %s", webhook_secret)
|
||||||
|
|
||||||
|
env_path = Path(".env")
|
||||||
|
env_vars = {
|
||||||
|
"STRIPE_WEBHOOK_SECRET": webhook_secret,
|
||||||
|
"STRIPE_WEBHOOK_ENDPOINT_ID": endpoint.id,
|
||||||
|
}
|
||||||
|
if env_path.exists():
|
||||||
|
env_text = env_path.read_text()
|
||||||
|
for key, value in env_vars.items():
|
||||||
|
pattern = rf"^{key}=.*$"
|
||||||
|
replacement = f"{key}={value}"
|
||||||
|
if re.search(pattern, env_text, flags=re.MULTILINE):
|
||||||
|
env_text = re.sub(pattern, replacement, env_text, flags=re.MULTILINE)
|
||||||
|
else:
|
||||||
|
env_text = env_text.rstrip("\n") + f"\n{replacement}\n"
|
||||||
|
env_path.write_text(env_text)
|
||||||
|
logger.info("STRIPE_WEBHOOK_SECRET and STRIPE_WEBHOOK_ENDPOINT_ID written to .env")
|
||||||
|
else:
|
||||||
|
logger.info("Add to .env:")
|
||||||
|
for key, value in env_vars.items():
|
||||||
|
logger.info(" %s=%s", key, value)
|
||||||
|
|
||||||
|
logger.info("Done. Remember to enable Stripe Tax in your Dashboard (Settings > Tax).")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
conn = _open_db()
|
||||||
|
|
||||||
|
try:
|
||||||
|
if "--sync" in sys.argv:
|
||||||
|
sync(conn)
|
||||||
|
else:
|
||||||
|
create(conn)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(levelname)-8s %(message)s")
|
||||||
|
main()
|
||||||
@@ -17,7 +17,8 @@ from ..core import (
|
|||||||
feature_gate,
|
feature_gate,
|
||||||
fetch_all,
|
fetch_all,
|
||||||
fetch_one,
|
fetch_one,
|
||||||
get_paddle_price,
|
get_all_price_ids,
|
||||||
|
get_price_id,
|
||||||
is_flag_enabled,
|
is_flag_enabled,
|
||||||
)
|
)
|
||||||
from ..i18n import get_translations
|
from ..i18n import get_translations
|
||||||
@@ -383,7 +384,9 @@ def _compute_order(data: dict, included_boosts: list, t: dict) -> dict:
|
|||||||
@bp.route("/signup/checkout", methods=["POST"])
|
@bp.route("/signup/checkout", methods=["POST"])
|
||||||
@csrf_protect
|
@csrf_protect
|
||||||
async def signup_checkout():
|
async def signup_checkout():
|
||||||
"""Validate form, return JSON for Paddle.js overlay checkout."""
|
"""Validate form, return checkout JSON (Paddle overlay or Stripe redirect)."""
|
||||||
|
from ..billing.routes import _provider
|
||||||
|
|
||||||
form = await request.form
|
form = await request.form
|
||||||
accumulated = _parse_accumulated(form)
|
accumulated = _parse_accumulated(form)
|
||||||
|
|
||||||
@@ -401,9 +404,9 @@ async def signup_checkout():
|
|||||||
if period == "yearly"
|
if period == "yearly"
|
||||||
else plan_info.get("paddle_key_monthly", plan)
|
else plan_info.get("paddle_key_monthly", plan)
|
||||||
)
|
)
|
||||||
plan_price_id = await get_paddle_price(price_key)
|
plan_price_id = await get_price_id(price_key)
|
||||||
if not plan_price_id:
|
if not plan_price_id:
|
||||||
return jsonify({"error": "Invalid plan selected. Run setup_paddle first."}), 400
|
return jsonify({"error": "Invalid plan selected. Run setup first."}), 400
|
||||||
|
|
||||||
# Build items list
|
# Build items list
|
||||||
items = [{"priceId": plan_price_id, "quantity": 1}]
|
items = [{"priceId": plan_price_id, "quantity": 1}]
|
||||||
@@ -416,14 +419,14 @@ async def signup_checkout():
|
|||||||
|
|
||||||
for b in BOOST_OPTIONS:
|
for b in BOOST_OPTIONS:
|
||||||
if b["type"] in selected_boosts and b["type"] not in included_boosts:
|
if b["type"] in selected_boosts and b["type"] not in included_boosts:
|
||||||
price_id = await get_paddle_price(b["key"])
|
price_id = await get_price_id(b["key"])
|
||||||
if price_id:
|
if price_id:
|
||||||
items.append({"priceId": price_id, "quantity": 1})
|
items.append({"priceId": price_id, "quantity": 1})
|
||||||
|
|
||||||
# Add credit pack (one-time)
|
# Add credit pack (one-time)
|
||||||
credit_pack = accumulated.get("credit_pack", "")
|
credit_pack = accumulated.get("credit_pack", "")
|
||||||
if credit_pack:
|
if credit_pack:
|
||||||
price_id = await get_paddle_price(credit_pack)
|
price_id = await get_price_id(credit_pack)
|
||||||
if price_id:
|
if price_id:
|
||||||
items.append({"priceId": price_id, "quantity": 1})
|
items.append({"priceId": price_id, "quantity": 1})
|
||||||
|
|
||||||
@@ -477,15 +480,12 @@ async def signup_checkout():
|
|||||||
"plan": plan,
|
"plan": plan,
|
||||||
}
|
}
|
||||||
|
|
||||||
return jsonify(
|
payload = _provider().build_multi_item_checkout_payload(
|
||||||
{
|
items=items,
|
||||||
"items": items,
|
custom_data=custom_data,
|
||||||
"customData": custom_data,
|
success_url=f"{config.BASE_URL}/suppliers/signup/success",
|
||||||
"settings": {
|
|
||||||
"successUrl": f"{config.BASE_URL}/suppliers/signup/success",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
return jsonify(payload)
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/claim/<slug>")
|
@bp.route("/claim/<slug>")
|
||||||
@@ -1035,12 +1035,8 @@ async def dashboard_boosts():
|
|||||||
(supplier["id"],),
|
(supplier["id"],),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Resolve Paddle price IDs for buy buttons
|
# Resolve price IDs for buy buttons (from active provider)
|
||||||
price_ids = {}
|
price_ids = await get_all_price_ids()
|
||||||
for b in BOOST_OPTIONS:
|
|
||||||
price_ids[b["key"]] = await get_paddle_price(b["key"])
|
|
||||||
for cp in CREDIT_PACK_OPTIONS:
|
|
||||||
price_ids[cp["key"]] = await get_paddle_price(cp["key"])
|
|
||||||
|
|
||||||
return await render_template(
|
return await render_template(
|
||||||
"suppliers/partials/dashboard_boosts.html",
|
"suppliers/partials/dashboard_boosts.html",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}{{ t.sd_page_title }} - {{ config.APP_NAME }}{% endblock %}
|
{% block title %}{{ t.sd_page_title }} - {{ config.APP_NAME }}{% endblock %}
|
||||||
{% block paddle %}{% include "_paddle.html" %}{% endblock %}
|
{% block paddle %}{% include "_payment_js.html" %}{% endblock %}
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -102,7 +102,7 @@
|
|||||||
<div class="bst-boost__price">€{{ b.price }}/mo</div>
|
<div class="bst-boost__price">€{{ b.price }}/mo</div>
|
||||||
{% if price_ids.get(b.key) %}
|
{% if price_ids.get(b.key) %}
|
||||||
<button type="button" class="bst-buy-btn"
|
<button type="button" class="bst-buy-btn"
|
||||||
onclick="Paddle.Checkout.open({items:[{priceId:'{{ price_ids[b.key] }}',quantity:1}],customData:{supplier_id:'{{ supplier.id }}'}})">
|
onclick="buyItem('{{ b.key }}', {supplier_id:'{{ supplier.id }}'}, this)">
|
||||||
{{ t.sd_bst_activate }}
|
{{ t.sd_bst_activate }}
|
||||||
</button>
|
</button>
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -125,7 +125,7 @@
|
|||||||
<div class="bst-credit-card__price">€{{ cp.price }}</div>
|
<div class="bst-credit-card__price">€{{ cp.price }}</div>
|
||||||
{% if price_ids.get(cp.key) %}
|
{% if price_ids.get(cp.key) %}
|
||||||
<button type="button" class="bst-buy-btn"
|
<button type="button" class="bst-buy-btn"
|
||||||
onclick="Paddle.Checkout.open({items:[{priceId:'{{ price_ids[cp.key] }}',quantity:1}],customData:{supplier_id:'{{ supplier.id }}'}})">
|
onclick="buyItem('{{ cp.key }}', {supplier_id:'{{ supplier.id }}'}, this)">
|
||||||
{{ t.sd_bst_buy }}
|
{{ t.sd_bst_buy }}
|
||||||
</button>
|
</button>
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -160,3 +160,27 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function buyItem(priceKey, customData, btn) {
|
||||||
|
var label = btn.textContent;
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = '...';
|
||||||
|
fetch('/billing/checkout/item', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({price_key: priceKey, custom_data: customData}),
|
||||||
|
})
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
if (data.error) { alert(data.error); }
|
||||||
|
else { startCheckout(data); }
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = label;
|
||||||
|
})
|
||||||
|
.catch(function() {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = label;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|||||||
@@ -124,11 +124,7 @@
|
|||||||
btn.textContent = {{ t.sup_step4_checkout | tojson }};
|
btn.textContent = {{ t.sup_step4_checkout | tojson }};
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Paddle.Checkout.open({
|
startCheckout(result.data);
|
||||||
items: result.data.items,
|
|
||||||
customData: result.data.customData,
|
|
||||||
settings: result.data.settings
|
|
||||||
});
|
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
btn.textContent = {{ t.sup_step4_checkout | tojson }};
|
btn.textContent = {{ t.sup_step4_checkout | tojson }};
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}{{ t.sup_signup_page_title }} - {{ config.APP_NAME }}{% endblock %}
|
{% block title %}{{ t.sup_signup_page_title }} - {{ config.APP_NAME }}{% endblock %}
|
||||||
{% block paddle %}{% include "_paddle.html" %}{% endblock %}
|
{% block paddle %}{% include "_payment_js.html" %}{% endblock %}
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
51
web/src/padelnomics/templates/_payment_js.html
Normal file
51
web/src/padelnomics/templates/_payment_js.html
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
{# Payment JS — conditionally loads provider SDK on checkout pages.
|
||||||
|
Include via {% block paddle %}{% include "_payment_js.html" %}{% endblock %}
|
||||||
|
Paddle: loads Paddle.js SDK + initializes overlay checkout.
|
||||||
|
Stripe: no SDK needed (server-side Checkout Session + redirect). #}
|
||||||
|
|
||||||
|
{% if config.PAYMENT_PROVIDER == "paddle" %}
|
||||||
|
<script defer src="https://cdn.paddle.com/paddle/v2/paddle.js"></script>
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
{% if config.PADDLE_ENVIRONMENT == "sandbox" %}
|
||||||
|
Paddle.Environment.set("sandbox");
|
||||||
|
{% endif %}
|
||||||
|
{% if config.PADDLE_CLIENT_TOKEN %}
|
||||||
|
Paddle.Initialize({
|
||||||
|
token: "{{ config.PADDLE_CLIENT_TOKEN }}",
|
||||||
|
eventCallback: function(ev) {
|
||||||
|
if (ev.name === "checkout.error") console.error("Paddle checkout error:", ev.data);
|
||||||
|
},
|
||||||
|
checkout: {
|
||||||
|
settings: {
|
||||||
|
displayMode: "overlay",
|
||||||
|
theme: "light",
|
||||||
|
locale: "en",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
{% else %}
|
||||||
|
console.warn("Paddle: PADDLE_CLIENT_TOKEN not configured");
|
||||||
|
{% endif %}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<script>
|
||||||
|
/**
|
||||||
|
* startCheckout — dual-path checkout handler.
|
||||||
|
* Paddle: opens overlay with items/customData/settings from response JSON.
|
||||||
|
* Stripe: redirects to checkout_url from response JSON.
|
||||||
|
*/
|
||||||
|
function startCheckout(data) {
|
||||||
|
if (data.checkout_url) {
|
||||||
|
window.location.href = data.checkout_url;
|
||||||
|
} else if (data.items) {
|
||||||
|
Paddle.Checkout.open({
|
||||||
|
items: data.items,
|
||||||
|
customData: data.customData,
|
||||||
|
settings: data.settings,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
Route integration tests for Paddle billing endpoints.
|
Route integration tests for billing endpoints.
|
||||||
Checkout uses Paddle.js overlay (returns JSON), manage/cancel use Paddle SDK.
|
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
|
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:
|
class TestCheckoutRoute:
|
||||||
@@ -48,12 +66,7 @@ class TestCheckoutRoute:
|
|||||||
assert response.status_code in (302, 303, 307)
|
assert response.status_code in (302, 303, 307)
|
||||||
|
|
||||||
async def test_returns_checkout_json(self, auth_client, db, test_user):
|
async def test_returns_checkout_json(self, auth_client, db, test_user):
|
||||||
# Insert a paddle_products row so get_paddle_price() finds it
|
await _insert_test_product(db)
|
||||||
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()
|
|
||||||
|
|
||||||
response = await auth_client.post(f"/billing/checkout/{CHECKOUT_PLAN}")
|
response = await auth_client.post(f"/billing/checkout/{CHECKOUT_PLAN}")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
@@ -89,7 +102,7 @@ class TestManageRoute:
|
|||||||
mock_client = MagicMock()
|
mock_client = MagicMock()
|
||||||
mock_client.subscriptions.get.return_value = mock_sub
|
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)
|
response = await auth_client.post("/billing/manage", follow_redirects=False)
|
||||||
assert response.status_code in (302, 303, 307)
|
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")
|
await create_subscription(test_user["id"], provider_subscription_id="sub_test")
|
||||||
|
|
||||||
mock_client = MagicMock()
|
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)
|
response = await auth_client.post("/billing/cancel", follow_redirects=False)
|
||||||
assert response.status_code in (302, 303, 307)
|
assert response.status_code in (302, 303, 307)
|
||||||
mock_client.subscriptions.cancel.assert_called_once()
|
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
|
# subscription_required decorator
|
||||||
# ════════════════════════════════════════════════════════════
|
# ════════════════════════════════════════════════════════════
|
||||||
|
|||||||
Reference in New Issue
Block a user