Skip to content

Commit 6f7aa86

Browse files
committed
Add change subscription API and frontend integration
1 parent 92b486f commit 6f7aa86

4 files changed

Lines changed: 261 additions & 8 deletions

File tree

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

Lines changed: 65 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,10 @@
1616
from langflow.services.paddle.provisioning import get_paddle_prices
1717
from langflow.services.paddle.subscriptions import (
1818
cancel_subscription,
19+
change_subscription,
1920
ensure_paddle_customer_for_user,
2021
fetch_active_subscription,
21-
get_subscriptions_by_customer_id,
2222
has_active_subscription,
23-
pick_active_subscription,
2423
retry_with_backoff,
2524
)
2625

@@ -30,6 +29,11 @@
3029
class EnsurePaddleCustomerRequest(BaseModel):
3130
email: str | None = None
3231

32+
class ChangeSubscriptionRequest(BaseModel):
33+
price_id: str
34+
quantity: int = 1
35+
is_upgrade: bool
36+
3337
@router.post("/ensure-paddle-customer")
3438
async def ensure_paddle_customer(
3539
current_user: CurrentActiveUser,
@@ -138,8 +142,13 @@ async def get_subscriptions_by_customer(
138142
if not customer_id:
139143
raise HTTPException(status_code=400, detail="Missing paddle_customer_id")
140144

141-
subscriptions = await get_subscriptions_by_customer_id(customer_id=customer_id)
142-
active_sub_id = pick_active_subscription(subscriptions, org_id=org_id)
145+
subscriptions = []
146+
active_sub_id = None
147+
148+
try:
149+
active_sub_id, subscriptions = await fetch_active_subscription(customer_id, org_id)
150+
except ValueError:
151+
pass
143152
if not active_sub_id:
144153
try:
145154
active_sub_id, subscriptions = await retry_with_backoff(
@@ -210,4 +219,56 @@ async def cancel_subscription_api(
210219
raise HTTPException(
211220
status_code=500,
212221
detail=f"Failed to cancel subscription: {str(e)}",
222+
)
223+
224+
@router.post("/change-subscription")
225+
async def change_subscription_api(
226+
body: ChangeSubscriptionRequest,
227+
current_user: CurrentActiveUser,
228+
) -> dict:
229+
"""Change subscription (Starter -> Pro)"""
230+
231+
logger.info(f"Change subscription API called by user {current_user.id}")
232+
233+
if not get_settings_service().auth_settings.CLERK_AUTH_ENABLED:
234+
raise HTTPException(status_code=400, detail="Clerk auth not enabled")
235+
236+
# 1️⃣ Get subscription_id from Clerk metadata
237+
subscription_id = await get_paddle_subscription_id_from_clerk_payload()
238+
239+
if not subscription_id:
240+
raise HTTPException(status_code=400, detail="Missing paddle_subscription_id")
241+
242+
logger.info(
243+
f"Upgrade request for subscription {subscription_id} "
244+
f"to price {body.price_id}"
245+
)
246+
247+
try:
248+
result = await change_subscription(
249+
subscription_id=subscription_id,
250+
new_price_id=body.price_id,
251+
quantity=body.quantity,
252+
is_upgrade=body.is_upgrade,
253+
)
254+
255+
logger.info(
256+
f"Successfully changed subscription {subscription_id} "
257+
f"to price {body.price_id}, status: {result.get('status')}"
258+
)
259+
260+
return {
261+
"success": True,
262+
"message": "Subscription changed successfully",
263+
**result,
264+
}
265+
266+
except Exception as e:
267+
logger.info(
268+
f"Failed to upgrade subscription {subscription_id} "
269+
f"for user {current_user.id}: {str(e)}"
270+
)
271+
raise HTTPException(
272+
status_code=500,
273+
detail=f"Failed to upgrade subscription: {str(e)}",
213274
)

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

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@
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, CancelSubscription
15-
from paddle_billing.Entities.Subscriptions import SubscriptionEffectiveFrom
14+
from paddle_billing.Resources.Subscriptions.Operations import ListSubscriptions, CancelSubscription, UpdateSubscription
15+
from paddle_billing.Entities.Subscriptions import SubscriptionEffectiveFrom, SubscriptionProrationBillingMode
16+
from paddle_billing.Resources.Subscriptions.Operations.Update import SubscriptionUpdateItem
17+
1618

1719
from langflow.services.auth.clerk_metadata_constants import (
1820
ORGANISATION_CREATED_BY_KEY,
@@ -510,4 +512,57 @@ async def cancel_subscription(
510512

511513
except Exception as e:
512514
logger.exception(f"Error cancelling subscription {subscription_id}: {e}")
513-
raise
515+
raise
516+
517+
async def change_subscription(
518+
*,
519+
subscription_id: str,
520+
new_price_id: str,
521+
quantity: int = 1,
522+
is_upgrade: bool,
523+
client: Client | None = None,
524+
):
525+
paddle_client = client or get_paddle_client()
526+
527+
subscription = await asyncio.to_thread(
528+
paddle_client.subscriptions.get,
529+
subscription_id,
530+
)
531+
532+
status = str(getattr(subscription, "status", "")).lower()
533+
534+
if is_upgrade:
535+
if status == "trialing":
536+
proration_mode = SubscriptionProrationBillingMode.DoNotBill
537+
else:
538+
proration_mode = SubscriptionProrationBillingMode.ProratedImmediately
539+
else:
540+
proration_mode = SubscriptionProrationBillingMode.DoNotBill
541+
542+
plan_key = "pro_pack_monthly" if is_upgrade else "starter_pack_monthly"
543+
544+
operation = UpdateSubscription(
545+
items=[
546+
SubscriptionUpdateItem(
547+
price_id=new_price_id,
548+
quantity=quantity,
549+
)
550+
],
551+
proration_billing_mode=proration_mode,
552+
custom_data=CustomData({
553+
"plan_key": plan_key,
554+
}),
555+
)
556+
557+
updated = await asyncio.to_thread(
558+
paddle_client.subscriptions.update,
559+
subscription_id,
560+
operation,
561+
)
562+
563+
return {
564+
"subscription_id": subscription_id,
565+
"status": getattr(updated, "status", None),
566+
"price_id": new_price_id,
567+
"proration_mode": str(proration_mode),
568+
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export const URLs = {
3535
GET_PADDLE_PRICES: `billing/paddle-prices`,
3636
GET_PADDLE_SUBSCRIPTION: `billing/get-subscriptions`,
3737
CANCEL_PADDLE_SUBSCRIPTION: `billing/cancel-subscription`,
38+
CHANGE_SUBSCRIPTION: `billing/change-subscription`,
3839
} as const;
3940

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

src/frontend/src/pages/SettingsPage/pages/PricingPlansPage/index.tsx

Lines changed: 137 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,13 +28,25 @@ type BillingAccessResponse = {
2828
cancel_scheduled?: boolean;
2929
};
3030

31+
type PaddlePricesResponse = Record<string, string>;
32+
3133
const PLAN_NAME_BY_KEY = Object.fromEntries(
3234
(planConfigData.plans ?? []).map((plan) => [
3335
String(plan?.paddle?.plan_key ?? "").toLowerCase(),
3436
String(plan?.name ?? "").trim(),
3537
]),
3638
) as Record<string, string>;
3739

40+
const PRO_PLAN_KEY = (
41+
planConfigData.plans?.find((plan) => plan?.key === "pro")?.paddle?.plan_key ??
42+
"pro_pack_monthly"
43+
).toLowerCase();
44+
45+
const STARTER_PLAN_KEY = (
46+
planConfigData.plans?.find((plan) => plan?.key === "starter")?.paddle?.plan_key ??
47+
"starter_pack_monthly"
48+
).toLowerCase();
49+
3850
function formatPlanKey(planKey: string): string {
3951
return planKey
4052
.replace(/[_-]+/g, " ")
@@ -80,6 +92,11 @@ export default function PricingPlansPage() {
8092

8193
const [cancelError, setCancelError] = useState<string | null>(null);
8294
const [alreadyCancelled, setAlreadyCancelled] = useState(false);
95+
const [upgradeLoading, setUpgradeLoading] = useState(false);
96+
const [upgradeError, setUpgradeError] = useState<string | null>(null);
97+
const [upgradeSuccess, setUpgradeSuccess] = useState<string | null>(null);
98+
99+
const [upgradeModalOpen, setUpgradeModalOpen] = useState(false);
83100

84101
const formattedNextBillingDate = useMemo(() => {
85102
const nextDateRaw = billing?.next_billed_at ?? billing?.current_period_end;
@@ -108,6 +125,14 @@ export default function PricingPlansPage() {
108125
!isCancelScheduled &&
109126
!alreadyCancelled;
110127

128+
const currentPlanKey = (billing?.subscription_plan_key ?? "").toLowerCase();
129+
const isProPlan = currentPlanKey === PRO_PLAN_KEY;
130+
131+
const canChangeSubscription =
132+
Boolean(billing?.paddle_subscription_id) &&
133+
!isCancelledState &&
134+
!isCancelScheduled;
135+
111136
useEffect(() => {
112137
if (!IS_CLERK_AUTH) {
113138
setLoading(false);
@@ -212,6 +237,58 @@ export default function PricingPlansPage() {
212237
}
213238
};
214239

240+
const handleChangeSubscription = async () => {
241+
setUpgradeLoading(true);
242+
setUpgradeError(null);
243+
setUpgradeSuccess(null);
244+
245+
try {
246+
const token = await getToken();
247+
if (!token) throw new Error("Missing auth token");
248+
249+
const { data: priceData } = await api.get<PaddlePricesResponse>(
250+
getURL("GET_PADDLE_PRICES"),
251+
{ headers: { Authorization: `Bearer ${token}` } },
252+
);
253+
254+
const targetPlanKey = isProPlan ? STARTER_PLAN_KEY : PRO_PLAN_KEY;
255+
const targetPriceId = priceData?.[targetPlanKey];
256+
257+
if (!targetPriceId) {
258+
throw new Error("Unable to find target price ID");
259+
}
260+
261+
await api.post(
262+
getURL("CHANGE_SUBSCRIPTION"),
263+
{
264+
price_id: targetPriceId,
265+
quantity: 1,
266+
is_upgrade: !isProPlan,
267+
},
268+
{ headers: { Authorization: `Bearer ${token}` } },
269+
);
270+
271+
const res = await api.get(getURL("BILLING_ACCESS"), {
272+
headers: { Authorization: `Bearer ${token}` },
273+
});
274+
275+
setBilling(res.data ?? null);
276+
setUpgradeSuccess(
277+
isProPlan
278+
? "Subscription will be downgraded at next billing cycle."
279+
: "Subscription upgraded successfully.",
280+
);
281+
} catch (error: any) {
282+
const msg =
283+
error?.response?.data?.detail ??
284+
error?.message ??
285+
"Failed to change subscription.";
286+
setUpgradeError(String(msg));
287+
} finally {
288+
setUpgradeLoading(false);
289+
}
290+
};
291+
215292
return (
216293
<div className="flex h-full w-full flex-col gap-6">
217294
<div className="flex flex-col">
@@ -245,6 +322,18 @@ export default function PricingPlansPage() {
245322
</div>
246323
)}
247324

325+
{upgradeError && (
326+
<div className="rounded-xl border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
327+
{upgradeError}
328+
</div>
329+
)}
330+
331+
{upgradeSuccess && (
332+
<div className="rounded-xl border border-emerald-500/30 bg-emerald-500/10 p-3 text-sm text-emerald-700 dark:text-emerald-300">
333+
{upgradeSuccess}
334+
</div>
335+
)}
336+
248337
{alreadyCancelled && (
249338
<div className="rounded-xl border border-amber-500/30 bg-amber-500/10 p-3 text-sm text-amber-700 dark:text-amber-300">
250339
You already cancelled your subscription. You still have access{" "}
@@ -261,6 +350,20 @@ export default function PricingPlansPage() {
261350
</Button>
262351
)}
263352

353+
{billing?.paddle_subscription_id && (
354+
<Button
355+
onClick={() => setUpgradeModalOpen(true)}
356+
disabled={upgradeLoading}
357+
className="ml-2"
358+
>
359+
{upgradeLoading
360+
? "Processing..."
361+
: isProPlan
362+
? "Downgrade to Starter"
363+
: "Upgrade to Pro"}
364+
</Button>
365+
)}
366+
264367
<Dialog open={cancelModalOpen} onOpenChange={setCancelModalOpen}>
265368
<DialogContent>
266369
<DialogHeader>
@@ -282,8 +385,41 @@ export default function PricingPlansPage() {
282385
</DialogFooter>
283386
</DialogContent>
284387
</Dialog>
388+
389+
<Dialog open={upgradeModalOpen} onOpenChange={setUpgradeModalOpen}>
390+
<DialogContent>
391+
<DialogHeader>
392+
<DialogTitle>
393+
{isProPlan ? "Downgrade to Starter" : "Upgrade to Pro"}
394+
</DialogTitle>
395+
<DialogDescription>
396+
{isProPlan
397+
? "Your plan will be downgraded at the next billing cycle."
398+
: "You will be charged a prorated amount today based on your remaining billing period."}
399+
</DialogDescription>
400+
</DialogHeader>
401+
<DialogFooter>
402+
<Button onClick={() => setUpgradeModalOpen(false)}>
403+
Cancel
404+
</Button>
405+
<Button
406+
onClick={async () => {
407+
setUpgradeModalOpen(false);
408+
await handleChangeSubscription();
409+
}}
410+
disabled={upgradeLoading}
411+
>
412+
{upgradeLoading
413+
? "Processing..."
414+
: isProPlan
415+
? "Confirm Downgrade"
416+
: "Confirm Upgrade"}
417+
</Button>
418+
</DialogFooter>
419+
</DialogContent>
420+
</Dialog>
285421
</div>
286422
)}
287423
</div>
288424
);
289-
}
425+
}

0 commit comments

Comments
 (0)