Skip to content

Commit 92b486f

Browse files
committed
Implement cancel subscription API and frontend integration
1 parent 45d0905 commit 92b486f

4 files changed

Lines changed: 311 additions & 47 deletions

File tree

src/backend/base/langflow/api/v1/billing.py

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from langflow.services.deps import get_settings_service
1616
from langflow.services.paddle.provisioning import get_paddle_prices
1717
from langflow.services.paddle.subscriptions import (
18+
cancel_subscription,
1819
ensure_paddle_customer_for_user,
1920
fetch_active_subscription,
2021
get_subscriptions_by_customer_id,
@@ -166,4 +167,47 @@ async def get_subscriptions_by_customer(
166167
"updated": True,
167168
"subscription_id": active_sub_id,
168169
"total_subscriptions": len(subscriptions),
169-
}
170+
}
171+
172+
class CancelSubscriptionRequest(BaseModel):
173+
effective_from_immediately: bool = False
174+
175+
176+
@router.post("/cancel-subscription")
177+
async def cancel_subscription_api(
178+
body: CancelSubscriptionRequest,
179+
current_user: CurrentActiveUser,
180+
) -> dict:
181+
"""Cancel subscription using Paddle"""
182+
183+
if not get_settings_service().auth_settings.CLERK_AUTH_ENABLED:
184+
raise HTTPException(status_code=400, detail="Clerk auth not enabled")
185+
186+
# Get subscription_id from Clerk metadata (JWT)
187+
subscription_id = await get_paddle_subscription_id_from_clerk_payload()
188+
189+
if not subscription_id:
190+
raise HTTPException(status_code=400, detail="Missing paddle_subscription_id")
191+
192+
logger.info(
193+
f"Cancel request for subscription {subscription_id}, "
194+
f"immediate={body.effective_from_immediately}"
195+
)
196+
197+
try:
198+
result = await cancel_subscription(
199+
subscription_id=subscription_id,
200+
effective_from_immediately=body.effective_from_immediately,
201+
)
202+
203+
return {
204+
"success": True,
205+
"message": "Subscription cancellation triggered",
206+
**result,
207+
}
208+
209+
except Exception as e:
210+
raise HTTPException(
211+
status_code=500,
212+
detail=f"Failed to cancel subscription: {str(e)}",
213+
)

src/backend/base/langflow/services/paddle/subscriptions.py

Lines changed: 80 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@
1111
from paddle_billing import Client #noqa: TCH002
1212
from paddle_billing.Entities.Shared import CustomData
1313
from paddle_billing.Resources.Customers.Operations import CreateCustomer, UpdateCustomer
14-
from paddle_billing.Resources.Subscriptions.Operations import ListSubscriptions
14+
from paddle_billing.Resources.Subscriptions.Operations import ListSubscriptions, CancelSubscription
15+
from paddle_billing.Entities.Subscriptions import SubscriptionEffectiveFrom
1516

1617
from langflow.services.auth.clerk_metadata_constants import (
1718
ORGANISATION_CREATED_BY_KEY,
@@ -75,13 +76,17 @@ async def has_active_subscription(
7576
"subscription_plan_key": None,
7677
"paddle_subscription_id": None,
7778
"organisation_created_by": created_by,
79+
"next_billed_at": None,
80+
"current_period_end": None,
81+
"cancel_scheduled": False,
7882
}
7983

8084
paddle_client = client or get_paddle_client()
8185
subscription = await asyncio.to_thread(
8286
paddle_client.subscriptions.get,
8387
sub_id,
8488
)
89+
8590
status = getattr(subscription, "status", None)
8691
if status:
8792
status = str(status).lower()
@@ -92,13 +97,35 @@ async def has_active_subscription(
9297
plan_key = subscription_custom_data.get("plan_key")
9398
if plan_key is not None:
9499
plan_key = str(plan_key)
95-
logger.info(f"Subscription {sub_id} - status: {status}, has_access: {has_access}, active_statuses: {ACTIVE_SUBSCRIPTION_STATUSES}")
100+
101+
next_billed_at = getattr(subscription, "next_billed_at", None)
102+
103+
current_period = getattr(subscription, "current_billing_period", None)
104+
current_period_end = None
105+
if current_period:
106+
current_period_end = getattr(current_period, "ends_at", None)
107+
108+
scheduled_change = getattr(subscription, "scheduled_change", None)
109+
cancel_scheduled = False
110+
if scheduled_change:
111+
action = getattr(scheduled_change, "action", None)
112+
cancel_scheduled = str(action).lower() == "cancel"
113+
114+
logger.info(
115+
f"Subscription {sub_id} - status: {status}, "
116+
f"has_access: {has_access}, next_billed_at: {next_billed_at}, "
117+
f"cancel_scheduled: {cancel_scheduled}"
118+
)
119+
96120
return {
97121
"has_access": has_access,
98122
"subscription_status": status,
99123
"subscription_plan_key": plan_key,
100124
"paddle_subscription_id": sub_id,
101125
"organisation_created_by": created_by,
126+
"next_billed_at": next_billed_at,
127+
"current_period_end": current_period_end,
128+
"cancel_scheduled": cancel_scheduled,
102129
}
103130

104131

@@ -433,3 +460,54 @@ async def retry_with_backoff(
433460
await asyncio.sleep(delay)
434461

435462
raise last_exception
463+
464+
465+
async def cancel_subscription(
466+
*,
467+
subscription_id: str,
468+
effective_from_immediately: bool = False,
469+
client: Client | None = None,
470+
):
471+
"""
472+
Cancel a Paddle subscription.
473+
474+
Args:
475+
subscription_id: Paddle subscription ID (sub_xxx)
476+
effective_from_immediately:
477+
True -> cancel immediately
478+
False -> cancel at next billing period (default)
479+
"""
480+
paddle_client = client or get_paddle_client()
481+
482+
# Map boolean -> Paddle enum
483+
effective_from = (
484+
SubscriptionEffectiveFrom.IMMEDIATELY
485+
if effective_from_immediately
486+
else SubscriptionEffectiveFrom.NEXT_BILLING_PERIOD
487+
)
488+
489+
operation = CancelSubscription(
490+
effective_from=effective_from
491+
)
492+
493+
try:
494+
subscription = await asyncio.to_thread(
495+
paddle_client.subscriptions.cancel,
496+
subscription_id,
497+
operation,
498+
)
499+
500+
logger.info(
501+
f"Cancelled subscription {subscription_id} "
502+
f"(effective_from={effective_from})"
503+
)
504+
505+
return {
506+
"subscription_id": subscription_id,
507+
"status": getattr(subscription, "status", None),
508+
"effective_from": str(effective_from),
509+
}
510+
511+
except Exception as e:
512+
logger.exception(f"Error cancelling subscription {subscription_id}: {e}")
513+
raise

src/frontend/src/controllers/API/helpers/constants.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { BASE_URL_API, BASE_URL_API_V2 } from "../../../constants/constants";
1+
import { BASE_URL_API, BASE_URL_API_V2 } from "../../../constants/constants";
22

33
export const URLs = {
44
TRANSACTIONS: `monitor/transactions`,
@@ -34,6 +34,7 @@ export const URLs = {
3434
BILLING_ACCESS: `billing/org-access`,
3535
GET_PADDLE_PRICES: `billing/paddle-prices`,
3636
GET_PADDLE_SUBSCRIPTION: `billing/get-subscriptions`,
37+
CANCEL_PADDLE_SUBSCRIPTION: `billing/cancel-subscription`,
3738
} as const;
3839

3940
// IMPORTANT: FOLDERS endpoint now points to 'projects' for backward compatibility

0 commit comments

Comments
 (0)