|
5 | 5 | import asyncio |
6 | 6 | import re |
7 | 7 | from typing import Any |
| 8 | +from uuid import UUID |
8 | 9 |
|
9 | 10 | from lfx.log.logger import logger |
10 | 11 | from paddle_billing import Client #noqa: TCH002 |
11 | | -from paddle_billing.Entities.Shared import CustomData |
| 12 | +from paddle_billing.Entities.Shared import CountryCode, CustomData |
12 | 13 | from paddle_billing.Resources.Customers.Operations import CreateCustomer, UpdateCustomer |
| 14 | +from paddle_billing.Resources.Addresses.Operations import CreateAddress |
13 | 15 |
|
14 | 16 | from langflow.services.auth.clerk_metadata_constants import ( |
15 | 17 | ORGANISATION_CREATED_BY_KEY, |
16 | 18 | PADDLE_CUSTOM_DATA_USER_ID_KEY, |
17 | 19 | PADDLE_CUSTOMER_ID_KEY, |
18 | 20 | PADDLE_SUBSCRIPTION_ID_KEY, |
| 21 | + PADDLE_PLAN_KEY, |
| 22 | + PADDLE_SEATS_KEY, |
| 23 | + PADDLE_SUBSCRIPTION_STATUS_KEY, |
| 24 | + PADDLE_TRIAL_END_KEY, |
19 | 25 | ) |
20 | 26 | from langflow.services.auth.clerk_utils import ( |
21 | 27 | get_clerk_user_id_from_payload, |
22 | 28 | get_email_from_clerk_payload, |
| 29 | + get_org_id_from_clerk_payload, |
23 | 30 | get_organisation_created_by_from_clerk_payload, |
24 | 31 | get_paddle_customer_id_from_clerk_payload, |
25 | 32 | get_paddle_subscription_id_from_clerk_payload, |
@@ -281,6 +288,180 @@ def _find_customer() -> Any | None: |
281 | 288 | return await asyncio.to_thread(_find_customer) |
282 | 289 |
|
283 | 290 |
|
| 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 | + |
284 | 465 | async def _sync_paddle_customer_metadata( |
285 | 466 | *, |
286 | 467 | client: Client, |
|
0 commit comments