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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user