Skip to content

Commit 3bf91d3

Browse files
committed
refactor: modify starter plan to create cardless transaction
1 parent 35b6714 commit 3bf91d3

9 files changed

Lines changed: 372 additions & 2 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ dependencies = [
2525
"networkx==3.4.2",
2626
"fake-useragent==1.5.1",
2727
"pyarrow==19.0.0",
28+
"pycountry>=24.6.1,<25.0.0",
2829
"wikipedia==1.4.0",
2930
"qdrant-client==1.9.2",
3031
"weaviate-client==4.10.2",

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

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,16 @@
1414
from langflow.services.paddle.subscriptions import (
1515
ensure_paddle_customer_for_user,
1616
has_active_subscription,
17+
start_trial_subscription,
1718
)
1819

1920
router = APIRouter(tags=["Billing"], prefix="/billing")
2021

22+
class StartTrialRequest(BaseModel):
23+
plan_key: str
24+
seats: int = 1
25+
country: str
26+
postal_code: str
2127

2228
class EnsurePaddleCustomerRequest(BaseModel):
2329
email: str | None = None
@@ -114,3 +120,25 @@ async def list_paddle_prices():
114120
except Exception as exc: #noqa: BLE001
115121
logger.exception(f"Error fetching Paddle prices {exc}")
116122
raise HTTPException(status_code=500, detail="Internal server error") #noqa: B904
123+
124+
@router.post("/start-trial")
125+
async def start_trial(
126+
payload: StartTrialRequest,
127+
current_user: CurrentActiveUser,
128+
) -> dict:
129+
"""Start a cardless trial subscription for first-time users."""
130+
if not get_settings_service().auth_settings.CLERK_AUTH_ENABLED:
131+
raise HTTPException(status_code=400, detail="Clerk auth not enabled")
132+
133+
try:
134+
return await start_trial_subscription(
135+
plan_key=payload.plan_key,
136+
seats=payload.seats,
137+
country=payload.country,
138+
postal_code=payload.postal_code,
139+
)
140+
except ValueError as exc:
141+
raise HTTPException(status_code=400, detail=str(exc)) from exc
142+
except Exception as exc: #noqa: BLE001
143+
logger.exception(f"Error starting trial subscription {exc}")
144+
raise HTTPException(status_code=500, detail="Internal server error") from exc

src/backend/base/langflow/services/auth/clerk_metadata_constants.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@
2323
PADDLE_CUSTOMER_ID_KEY = "paddle_customer_id" # Paddle customer ID
2424
PADDLE_SUBSCRIPTION_ID_KEY = "paddle_subscription_id" # Paddle subscription ID
2525
ORGANISATION_CREATED_BY_KEY = "organisation_created_by" # Clerk user id of org creator/admin
26+
PADDLE_PLAN_KEY = "paddle_plan_key" # Active plan key
27+
PADDLE_SUBSCRIPTION_STATUS_KEY = "paddle_subscription_status" # Active subscription status
28+
PADDLE_TRIAL_END_KEY = "paddle_trial_end" # Trial end timestamp
29+
PADDLE_SEATS_KEY = "paddle_seats" # Active seat count
2630

2731
# ============================================================================
2832
# Custom Data Keys (stored in Paddle customer custom_data)

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

Lines changed: 182 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,21 +5,28 @@
55
import asyncio
66
import re
77
from typing import Any
8+
from uuid import UUID
89

910
from lfx.log.logger import logger
1011
from paddle_billing import Client #noqa: TCH002
11-
from paddle_billing.Entities.Shared import CustomData
12+
from paddle_billing.Entities.Shared import CountryCode, CustomData
1213
from paddle_billing.Resources.Customers.Operations import CreateCustomer, UpdateCustomer
14+
from paddle_billing.Resources.Addresses.Operations import CreateAddress
1315

1416
from langflow.services.auth.clerk_metadata_constants import (
1517
ORGANISATION_CREATED_BY_KEY,
1618
PADDLE_CUSTOM_DATA_USER_ID_KEY,
1719
PADDLE_CUSTOMER_ID_KEY,
1820
PADDLE_SUBSCRIPTION_ID_KEY,
21+
PADDLE_PLAN_KEY,
22+
PADDLE_SEATS_KEY,
23+
PADDLE_SUBSCRIPTION_STATUS_KEY,
24+
PADDLE_TRIAL_END_KEY,
1925
)
2026
from langflow.services.auth.clerk_utils import (
2127
get_clerk_user_id_from_payload,
2228
get_email_from_clerk_payload,
29+
get_org_id_from_clerk_payload,
2330
get_organisation_created_by_from_clerk_payload,
2431
get_paddle_customer_id_from_clerk_payload,
2532
get_paddle_subscription_id_from_clerk_payload,
@@ -281,6 +288,180 @@ def _find_customer() -> Any | None:
281288
return await asyncio.to_thread(_find_customer)
282289

283290

291+
async def start_trial_subscription(
292+
*,
293+
plan_key: str,
294+
seats: int,
295+
country: str,
296+
postal_code: str,
297+
client: Client | None = None,
298+
) -> dict[str, Any]:
299+
if seats < 1:
300+
msg = "seats must be at least 1"
301+
raise ValueError(msg)
302+
303+
normalized_country = _normalize_country_for_paddle(country)
304+
normalized_postal_code = postal_code.strip()
305+
if not normalized_postal_code:
306+
msg = "postal_code is required"
307+
raise ValueError(msg)
308+
309+
paddle_client = client or get_paddle_client()
310+
org_id = get_org_id_from_clerk_payload()
311+
organisation_created_by = get_clerk_user_id_from_payload()
312+
313+
subscription_id = await get_paddle_subscription_id_from_clerk_payload()
314+
if subscription_id:
315+
msg = "subscription already exists for current user/session"
316+
raise ValueError(msg)
317+
318+
customer_id = await ensure_paddle_customer_for_user(client=paddle_client)
319+
if not customer_id:
320+
msg = "unable to resolve paddle customer"
321+
raise ValueError(msg)
322+
323+
from langflow.services.paddle.provisioning import get_paddle_prices
324+
325+
price_map = await get_paddle_prices(client=paddle_client)
326+
price_id = price_map.get(plan_key)
327+
if not price_id:
328+
msg = f"no Paddle price mapped for plan_key={plan_key}"
329+
raise ValueError(msg)
330+
331+
address_id = await _create_paddle_address_for_customer(
332+
client=paddle_client,
333+
customer_id=customer_id,
334+
country_code=normalized_country,
335+
postal_code=normalized_postal_code,
336+
)
337+
338+
payload = {
339+
"items": [{"price_id": price_id, "quantity": seats}],
340+
"customer_id": customer_id,
341+
"address_id": address_id,
342+
"status": "billed",
343+
}
344+
345+
transaction = await asyncio.to_thread(paddle_client.transactions.create, payload)
346+
details = _extract_subscription_details_from_transaction(transaction)
347+
348+
await update_clerk_organization(
349+
org_id=org_id,
350+
public_metadata={
351+
PADDLE_SUBSCRIPTION_ID_KEY: details["subscription_id"],
352+
ORGANISATION_CREATED_BY_KEY: organisation_created_by,
353+
},
354+
max_allowed_members=seats,
355+
)
356+
357+
await update_clerk_user_metadata(
358+
clerk_user_id=organisation_created_by,
359+
public_metadata={
360+
PADDLE_SUBSCRIPTION_ID_KEY: details["subscription_id"],
361+
PADDLE_PLAN_KEY: plan_key,
362+
PADDLE_SUBSCRIPTION_STATUS_KEY: details["status"],
363+
PADDLE_TRIAL_END_KEY: details["trial_end"],
364+
PADDLE_SEATS_KEY: seats,
365+
},
366+
)
367+
368+
return {
369+
"subscription_id": details["subscription_id"],
370+
"status": details["status"],
371+
"trial_end": details["trial_end"],
372+
"plan_key": plan_key,
373+
"seats": seats,
374+
}
375+
376+
377+
def _normalize_country_for_paddle(country: str) -> CountryCode:
378+
value = country.strip()
379+
if not value:
380+
raise ValueError("country is required")
381+
382+
try:
383+
import pycountry
384+
# try uppercase first (for ISO alpha-2)
385+
result = pycountry.countries.lookup(value.upper())
386+
iso2 = result.alpha_2.upper()
387+
logger.info(f"Normalized country '{value}' to ISO2 code: {iso2}")
388+
return CountryCode(iso2)
389+
390+
except LookupError:
391+
raise ValueError(f"invalid or unsupported country: {country}")
392+
393+
394+
def _extract_address_id(address: Any) -> str:
395+
address_id = getattr(address, "id", None)
396+
if isinstance(address_id, UUID):
397+
return str(address_id)
398+
if isinstance(address_id, str) and address_id.strip():
399+
return address_id.strip()
400+
401+
data = _normalize_custom_data(getattr(address, "data", None))
402+
nested_id = data.get("id") if isinstance(data, dict) else None
403+
if isinstance(nested_id, UUID):
404+
return str(nested_id)
405+
if isinstance(nested_id, str) and nested_id.strip():
406+
return nested_id.strip()
407+
408+
msg = "Paddle address response missing id"
409+
raise ValueError(msg)
410+
411+
412+
async def _create_paddle_address_for_customer(
413+
*,
414+
client: Client,
415+
customer_id: str,
416+
country_code: CountryCode,
417+
postal_code: str,
418+
) -> str:
419+
420+
operation = CreateAddress(
421+
country_code=country_code,
422+
postal_code=postal_code,
423+
)
424+
425+
address = await asyncio.to_thread(
426+
client.addresses.create,
427+
customer_id,
428+
operation,
429+
)
430+
431+
logger.info(f"Created Paddle address for customer {customer_id}: {address}")
432+
433+
return _extract_address_id(address)
434+
435+
436+
def _extract_subscription_details_from_transaction(transaction: Any) -> dict[str, str]:
437+
data = _normalize_custom_data(getattr(transaction, "data", None)) or transaction
438+
if not isinstance(data, dict):
439+
data = {}
440+
441+
subscription_id = (
442+
data.get("subscription_id")
443+
or _normalize_custom_data(data.get("subscription", None)).get("id")
444+
)
445+
status = data.get("status") or _normalize_custom_data(data.get("subscription", None)).get("status")
446+
trial_end = (
447+
data.get("next_billed_at")
448+
or _normalize_custom_data(data.get("subscription", None)).get("next_billed_at")
449+
)
450+
451+
if not isinstance(subscription_id, str) or not subscription_id.strip():
452+
msg = "Paddle transaction response missing subscription_id"
453+
raise ValueError(msg)
454+
455+
normalized_status = str(status).strip() if status else "trialing"
456+
normalized_trial_end = str(trial_end).strip() if trial_end else ""
457+
458+
return {
459+
"subscription_id": subscription_id.strip(),
460+
"status": normalized_status,
461+
"trial_end": normalized_trial_end,
462+
}
463+
464+
284465
async def _sync_paddle_customer_metadata(
285466
*,
286467
client: Client,

src/backend/base/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ dependencies = [
9292
"langchain-ibm>=0.3.8,<1.0.0",
9393
"trustcall>=0.0.38,<1.0.0",
9494
"langchain-chroma>=0.1.4,<1.0.0",
95+
"pycountry>=24.6.1,<25.0.0",
9596
]
9697

9798
[dependency-groups]

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ export const URLs = {
3333
KNOWLEDGE_BASES: `knowledge_bases`,
3434
BILLING_ACCESS: `billing/org-access`,
3535
GET_PADDLE_PRICES: `billing/paddle-prices`,
36+
START_TRIAL: `billing/start-trial`,
3637
} as const;
3738

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

0 commit comments

Comments
 (0)