switch payment provider from LemonSqueezy to Paddle

Run copier update with payment_provider=paddle to switch all billing
integration: routes, config, schema columns, webhook handling, tests,
and CI deploy secrets. Add one-time migration script for renaming
lemonsqueezy_* columns to paddle_* in production DB.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Deeman
2026-02-16 10:40:40 +01:00
parent 53ca195a49
commit 25d06a80d5
11 changed files with 406 additions and 771 deletions

View File

@@ -1,15 +1,17 @@
"""
Route tests for billing pages, checkout, manage, cancel, resume.
External LemonSqueezy API calls mocked with respx.
Route integration tests for Paddle billing endpoints.
External Paddle API calls mocked with respx.
"""
import json
import httpx
import pytest
import respx
CHECKOUT_METHOD = "POST"
CHECKOUT_PLAN = "starter"
# ════════════════════════════════════════════════════════════
# GET /billing/pricing
# Public routes (pricing, success)
# ════════════════════════════════════════════════════════════
class TestPricingPage:
@@ -27,15 +29,10 @@ class TestPricingPage:
assert response.status_code == 200
# ════════════════════════════════════════════════════════════
# GET /billing/success
# ════════════════════════════════════════════════════════════
class TestSuccessPage:
async def test_requires_auth(self, client, db):
response = await client.get("/billing/success")
assert response.status_code == 302
assert "/auth/login" in response.headers["Location"]
response = await client.get("/billing/success", follow_redirects=False)
assert response.status_code in (302, 303, 307)
async def test_accessible_with_auth(self, auth_client, db, test_user):
response = await auth_client.get("/billing/success")
@@ -43,266 +40,196 @@ class TestSuccessPage:
# ════════════════════════════════════════════════════════════
# GET /billing/checkout/<plan>
# Checkout
# ════════════════════════════════════════════════════════════
class TestCheckoutRoute:
async def test_requires_auth(self, client, db):
response = await client.get("/billing/checkout/monthly")
assert response.status_code == 302
assert "/auth/login" in response.headers["Location"]
response = await client.post(f"/billing/checkout/{CHECKOUT_PLAN}", follow_redirects=False)
assert response.status_code in (302, 303, 307)
@respx.mock
async def test_monthly_redirects_to_checkout_url(self, auth_client, db, test_user):
checkout_url = "https://checkout.lemonsqueezy.com/checkout/test123"
respx.post("https://api.lemonsqueezy.com/v1/checkouts").mock(
return_value=httpx.Response(
200, json={"data": {"attributes": {"url": checkout_url}}},
)
async def test_creates_checkout_session(self, auth_client, db, test_user):
respx.post("https://api.paddle.com/transactions").mock(
return_value=httpx.Response(200, json={
"data": {
"checkout": {
"url": "https://checkout.paddle.com/test_123"
}
}
})
)
response = await auth_client.get("/billing/checkout/monthly")
assert response.status_code == 302
assert response.headers["Location"] == checkout_url
response = await auth_client.post(f"/billing/checkout/{CHECKOUT_PLAN}", follow_redirects=False)
assert response.status_code in (302, 303, 307)
@respx.mock
async def test_yearly_redirects_to_checkout_url(self, auth_client, db, test_user):
checkout_url = "https://checkout.lemonsqueezy.com/checkout/yearly456"
respx.post("https://api.lemonsqueezy.com/v1/checkouts").mock(
return_value=httpx.Response(
200, json={"data": {"attributes": {"url": checkout_url}}},
)
)
response = await auth_client.get("/billing/checkout/yearly")
assert response.status_code == 302
assert response.headers["Location"] == checkout_url
async def test_invalid_plan_redirects_to_pricing(self, auth_client, db, test_user):
response = await auth_client.get("/billing/checkout/enterprise")
assert response.status_code == 302
assert "/billing/pricing" in response.headers["Location"]
@respx.mock
async def test_ajax_returns_json(self, auth_client, db, test_user):
checkout_url = "https://checkout.lemonsqueezy.com/checkout/ajax789"
respx.post("https://api.lemonsqueezy.com/v1/checkouts").mock(
return_value=httpx.Response(
200, json={"data": {"attributes": {"url": checkout_url}}},
)
)
response = await auth_client.get(
"/billing/checkout/monthly",
headers={"X-Requested-With": "XMLHttpRequest"},
)
assert response.status_code == 200
data = await response.get_json()
assert data["checkout_url"] == checkout_url
@respx.mock
async def test_sends_correct_api_payload(self, auth_client, db, test_user):
route = respx.post("https://api.lemonsqueezy.com/v1/checkouts").mock(
return_value=httpx.Response(
200, json={"data": {"attributes": {"url": "https://example.com"}}},
)
)
await auth_client.get("/billing/checkout/monthly")
assert route.called
sent_json = json.loads(route.calls.last.request.content)
assert sent_json["data"]["type"] == "checkouts"
assert sent_json["data"]["attributes"]["checkout_data"]["email"] == test_user["email"]
assert sent_json["data"]["attributes"]["checkout_data"]["custom"]["user_id"] == str(test_user["id"])
assert sent_json["data"]["relationships"]["store"]["data"]["id"] == "store_999"
assert sent_json["data"]["relationships"]["variant"]["data"]["id"] == "variant_monthly_100"
async def test_invalid_plan_rejected(self, auth_client, db, test_user):
response = await auth_client.post("/billing/checkout/invalid", follow_redirects=False)
assert response.status_code in (302, 303, 307)
@respx.mock
async def test_api_error_propagates(self, auth_client, db, test_user):
respx.post("https://api.lemonsqueezy.com/v1/checkouts").mock(
respx.post("https://api.paddle.com/transactions").mock(
return_value=httpx.Response(500, json={"error": "server error"})
)
with pytest.raises(httpx.HTTPStatusError):
await auth_client.get("/billing/checkout/monthly")
await auth_client.post(f"/billing/checkout/{CHECKOUT_PLAN}")
# ════════════════════════════════════════════════════════════
# POST /billing/manage
# Manage subscription / Portal
# ════════════════════════════════════════════════════════════
class TestManageRoute:
async def test_requires_auth(self, client, db):
response = await client.post("/billing/manage")
assert response.status_code == 302
assert "/auth/login" in response.headers["Location"]
response = await client.post("/billing/manage", follow_redirects=False)
assert response.status_code in (302, 303, 307)
async def test_no_subscription_redirects(self, auth_client, db, test_user):
response = await auth_client.post("/billing/manage")
assert response.status_code == 302
assert "/dashboard" in response.headers["Location"]
async def test_requires_subscription(self, auth_client, db, test_user):
response = await auth_client.post("/billing/manage", follow_redirects=False)
assert response.status_code in (302, 303, 307)
@respx.mock
async def test_redirects_to_portal(self, auth_client, db, test_user, create_subscription):
await create_subscription(test_user["id"], ls_subscription_id="sub_manage_001")
portal_url = "https://app.lemonsqueezy.com/my-orders/portal"
await create_subscription(test_user["id"], paddle_subscription_id="sub_test")
respx.get("https://api.lemonsqueezy.com/v1/subscriptions/sub_manage_001").mock(
respx.get("https://api.paddle.com/subscriptions/sub_test").mock(
return_value=httpx.Response(200, json={
"data": {"attributes": {"urls": {"customer_portal": portal_url}}}
"data": {
"management_urls": {
"update_payment_method": "https://paddle.com/manage/test_123"
}
}
})
)
response = await auth_client.post("/billing/manage")
assert response.status_code == 302
assert response.headers["Location"] == portal_url
response = await auth_client.post("/billing/manage", follow_redirects=False)
assert response.status_code in (302, 303, 307)
# ════════════════════════════════════════════════════════════
# POST /billing/cancel
# Cancel subscription
# ════════════════════════════════════════════════════════════
class TestCancelRoute:
async def test_requires_auth(self, client, db):
response = await client.post("/billing/cancel")
assert response.status_code == 302
assert "/auth/login" in response.headers["Location"]
response = await client.post("/billing/cancel", follow_redirects=False)
assert response.status_code in (302, 303, 307)
async def test_no_subscription_redirects(self, auth_client, db, test_user):
response = await auth_client.post("/billing/cancel")
assert response.status_code == 302
assert "/dashboard" in response.headers["Location"]
async def test_no_error_without_subscription(self, auth_client, db, test_user):
response = await auth_client.post("/billing/cancel", follow_redirects=False)
assert response.status_code in (302, 303, 307)
@respx.mock
async def test_sends_cancel_patch(self, auth_client, db, test_user, create_subscription):
await create_subscription(test_user["id"], ls_subscription_id="sub_cancel_route")
async def test_cancels_subscription(self, auth_client, db, test_user, create_subscription):
await create_subscription(test_user["id"], paddle_subscription_id="sub_test")
route = respx.patch(
"https://api.lemonsqueezy.com/v1/subscriptions/sub_cancel_route"
).mock(return_value=httpx.Response(200, json={}))
response = await auth_client.post("/billing/cancel")
assert response.status_code == 302
assert "/dashboard" in response.headers["Location"]
assert route.called
sent_json = json.loads(route.calls.last.request.content)
assert sent_json["data"]["attributes"]["cancelled"] is True
# ════════════════════════════════════════════════════════════
# POST /billing/resume
# ════════════════════════════════════════════════════════════
class TestResumeRoute:
async def test_requires_auth(self, client, db):
response = await client.post("/billing/resume")
assert response.status_code == 302
assert "/auth/login" in response.headers["Location"]
@respx.mock
async def test_sends_resume_patch(self, auth_client, db, test_user, create_subscription):
await create_subscription(test_user["id"], ls_subscription_id="sub_resume_route")
route = respx.patch(
"https://api.lemonsqueezy.com/v1/subscriptions/sub_resume_route"
).mock(return_value=httpx.Response(200, json={}))
response = await auth_client.post("/billing/resume")
assert response.status_code == 302
assert "/dashboard" in response.headers["Location"]
assert route.called
sent_json = json.loads(route.calls.last.request.content)
assert sent_json["data"]["attributes"]["cancelled"] is False
respx.post("https://api.paddle.com/subscriptions/sub_test/cancel").mock(
return_value=httpx.Response(200, json={"data": {}})
)
response = await auth_client.post("/billing/cancel", follow_redirects=False)
assert response.status_code in (302, 303, 307)
# ════════════════════════════════════════════════════════════
# subscription_required decorator
# ════════════════════════════════════════════════════════════
class TestSubscriptionRequired:
from quart import Blueprint # noqa: E402
from padelnomics.billing.routes import subscription_required # noqa: E402
test_bp = Blueprint("test", __name__)
@test_bp.route("/protected")
@subscription_required()
async def protected_route():
return "success", 200
@test_bp.route("/custom_allowed")
@subscription_required(allowed=("active", "past_due"))
async def custom_allowed_route():
return "success", 200
class TestSubscriptionRequiredDecorator:
@pytest.fixture
async def gated_app(self, app):
"""Register a test route using subscription_required with restricted allowed."""
from padelnomics.billing.routes import subscription_required
@app.route("/test-gated")
@subscription_required(allowed=("active", "on_trial"))
async def gated():
return "OK", 200
async def test_app(self, app):
app.register_blueprint(test_bp)
return app
async def test_no_session_redirects_to_login(self, gated_app, db):
async with gated_app.test_client() as c:
response = await c.get("/test-gated")
assert response.status_code == 302
assert "/auth/login" in response.headers["Location"]
@pytest.fixture
async def test_client(self, test_app):
async with test_app.test_client() as c:
yield c
async def test_no_subscription_redirects_to_pricing(self, gated_app, db, test_user):
async with gated_app.test_client() as c:
async with c.session_transaction() as sess:
sess["user_id"] = test_user["id"]
response = await c.get("/test-gated")
assert response.status_code == 302
assert "/billing/pricing" in response.headers["Location"]
async def test_redirects_unauthenticated(self, test_client, db):
response = await test_client.get("/protected", follow_redirects=False)
assert response.status_code in (302, 303, 307)
async def test_active_passes(self, gated_app, db, test_user, create_subscription):
await create_subscription(test_user["id"], status="active")
async with gated_app.test_client() as c:
async with c.session_transaction() as sess:
sess["user_id"] = test_user["id"]
response = await c.get("/test-gated")
assert response.status_code == 200
async def test_on_trial_passes(self, gated_app, db, test_user, create_subscription):
await create_subscription(test_user["id"], status="on_trial")
async with gated_app.test_client() as c:
async with c.session_transaction() as sess:
sess["user_id"] = test_user["id"]
response = await c.get("/test-gated")
assert response.status_code == 200
async def test_cancelled_rejected_when_not_in_allowed(self, gated_app, db, test_user, create_subscription):
await create_subscription(test_user["id"], status="cancelled")
async with gated_app.test_client() as c:
async with c.session_transaction() as sess:
sess["user_id"] = test_user["id"]
response = await c.get("/test-gated")
assert response.status_code == 302
assert "/billing/pricing" in response.headers["Location"]
async def test_expired_redirects(self, gated_app, db, test_user, create_subscription):
await create_subscription(test_user["id"], status="expired")
async with gated_app.test_client() as c:
async with c.session_transaction() as sess:
sess["user_id"] = test_user["id"]
response = await c.get("/test-gated")
assert response.status_code == 302
# ════════════════════════════════════════════════════════════
# Parameterized: subscription_required default allowed
# ════════════════════════════════════════════════════════════
ALL_STATUSES = ["free", "active", "on_trial", "cancelled", "past_due", "paused", "expired"]
DEFAULT_ALLOWED = ("active", "on_trial", "cancelled")
@pytest.mark.parametrize("status", ALL_STATUSES)
async def test_subscription_required_default_allowed(app, db, test_user, create_subscription, status):
from padelnomics.billing.routes import subscription_required
@app.route(f"/test-gate-{status}")
@subscription_required()
async def gated():
return "OK", 200
if status != "free":
await create_subscription(test_user["id"], status=status)
async with app.test_client() as c:
async with c.session_transaction() as sess:
async def test_redirects_without_subscription(self, test_client, db, test_user):
async with test_client.session_transaction() as sess:
sess["user_id"] = test_user["id"]
response = await c.get(f"/test-gate-{status}")
if status in DEFAULT_ALLOWED:
response = await test_client.get("/protected", follow_redirects=False)
assert response.status_code in (302, 303, 307)
async def test_allows_active_subscription(self, test_client, db, test_user, create_subscription):
await create_subscription(test_user["id"], plan="pro", status="active")
async with test_client.session_transaction() as sess:
sess["user_id"] = test_user["id"]
response = await test_client.get("/protected")
assert response.status_code == 200
async def test_allows_on_trial(self, test_client, db, test_user, create_subscription):
await create_subscription(test_user["id"], plan="pro", status="on_trial")
async with test_client.session_transaction() as sess:
sess["user_id"] = test_user["id"]
response = await test_client.get("/protected")
assert response.status_code == 200
async def test_allows_cancelled(self, test_client, db, test_user, create_subscription):
await create_subscription(test_user["id"], plan="pro", status="cancelled")
async with test_client.session_transaction() as sess:
sess["user_id"] = test_user["id"]
response = await test_client.get("/protected")
assert response.status_code == 200
async def test_rejects_expired(self, test_client, db, test_user, create_subscription):
await create_subscription(test_user["id"], plan="pro", status="expired")
async with test_client.session_transaction() as sess:
sess["user_id"] = test_user["id"]
response = await test_client.get("/protected", follow_redirects=False)
assert response.status_code in (302, 303, 307)
@pytest.mark.parametrize("status", ["free", "active", "on_trial", "cancelled", "past_due", "paused", "expired"])
async def test_default_allowed_tuple(self, test_client, db, test_user, create_subscription, status):
if status != "free":
await create_subscription(test_user["id"], plan="pro", status=status)
async with test_client.session_transaction() as sess:
sess["user_id"] = test_user["id"]
response = await test_client.get("/protected", follow_redirects=False)
if status in ("active", "on_trial", "cancelled"):
assert response.status_code == 200
else:
assert response.status_code == 302
assert response.status_code in (302, 303, 307)
async def test_custom_allowed_tuple(self, test_client, db, test_user, create_subscription):
await create_subscription(test_user["id"], plan="pro", status="past_due")
async with test_client.session_transaction() as sess:
sess["user_id"] = test_user["id"]
response = await test_client.get("/custom_allowed")
assert response.status_code == 200