feat(billing): switch to Stripe Checkout for subscriptions - #320
Conversation
## Walkthrough
The subscription upgrade and billing flow was refactored to use Stripe Checkout sessions instead of direct Stripe API and client-side payment confirmation. Server-side logic now creates a Checkout session and returns its URL, while client-side code redirects users to Stripe for payment. Webhook handling was added for post-checkout subscription updates.
## Changes
| File(s) | Change Summary |
|---------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| apps/api/.env.example | Added `UI_URL` variable set to `https://llmgateway.io`. |
| apps/api/src/routes/subscriptions.ts | Refactored pro subscription creation to use Stripe Checkout sessions; removed direct Stripe subscription logic, updated response schema to return `checkoutUrl`. |
| apps/api/src/stripe.ts | Added handler for `checkout.session.completed` Stripe webhook event to update organization plan, record transaction, and track analytics. |
| apps/ui/src/components/billing/plan-management.tsx | Removed Stripe SDK payment handling; now redirects user to Stripe Checkout via received `checkoutUrl` and updates UI text accordingly. |
| apps/ui/src/components/landing/pricing-plans.tsx | Changed subscription creation to expect and use a `checkoutUrl` for redirecting to Stripe Checkout; removed client-side payment status handling. |
| apps/ui/src/lib/api/v1.d.ts | Updated API response schema for `/subscriptions/create-pro-subscription` to return `{ checkoutUrl: string }` and changed response description. |
| apps/ui/src/routes/dashboard/_layout/settings/billing.tsx | Added validation for `success` and `canceled` query parameters; displays toast notifications based on checkout outcome and clears query after showing toast. |
| apps/ui/src/components/dashboard/dashboard-sidebar.tsx | Modified "Upgrade" link to clear `success` and `canceled` query parameters when navigating to billing settings. |
## Sequence Diagram(s)
```mermaid
sequenceDiagram
participant User
participant UI (Frontend)
participant API (Backend)
participant Stripe
User->>UI (Frontend): Click "Upgrade to Pro"
UI (Frontend)->>API (Backend): POST /subscriptions/create-pro-subscription
API (Backend)->>Stripe: Create Checkout Session
Stripe-->>API (Backend): Checkout Session URL
API (Backend)-->>UI (Frontend): { checkoutUrl }
UI (Frontend)->>User: Redirect to Stripe Checkout
User->>Stripe: Complete payment
Stripe-->>API (Backend): Webhook: checkout.session.completed
API (Backend)->>DB: Update org plan, record transaction
API (Backend)->>Analytics: Track subscription event
API (Backend)-->>UI (Frontend): (via query param) success/canceled
UI (Frontend)->>User: Show toast notificationSuggested reviewers
|
There was a problem hiding this comment.
Actionable comments posted: 6
🔭 Outside diff range comments (1)
apps/api/src/routes/subscriptions.ts (1)
166-168:⚠️ Potential issueRemove or justify the artificial delay in webhook processing.
The 5-second delay after updating the subscription seems arbitrary and could negatively impact user experience. Webhook processing should be handled asynchronously without blocking the API response.
Apply this diff to remove the delay:
-// let the webhook handler the rest to unify the logic -await new Promise((resolve) => { - setTimeout(resolve, 5000); -}); +// Webhook will handle the database updates asynchronouslyThe same issue appears in the
resumeProSubscriptionfunction at lines 248-251.
🧹 Nitpick comments (3)
apps/api/.env.example (1)
8-8: Consider adding environment-specific guidance for UI_URL.The hardcoded production URL might not be suitable for development or staging environments. Consider adding a comment indicating that this should be adjusted per environment.
+# Base URL for the frontend application (adjust per environment) +# Development: http://localhost:3000 +# Production: https://llmgateway.io UI_URL=https://llmgateway.ioapps/ui/src/components/billing/plan-management.tsx (1)
256-264: Consider preserving the subscription dialog state during errors.When an error occurs, the dialog remains open but the user might lose context after seeing the error toast. Since the code now redirects on success, the
onSuccesscallback is never called, which could leave the dialog in an inconsistent state if the user navigates back from Stripe.Do you want me to help implement a more robust error handling approach that considers the dialog lifecycle and browser navigation?
apps/api/src/routes/subscriptions.ts (1)
90-95: Consider adding customer email to the checkout session.The checkout session is created without specifying the customer email. While the customer ID is provided, including the email can improve the checkout experience by pre-filling the email field.
#!/bin/bash # Check if customer email is available in the organization model ast-grep --pattern 'interface $_ { $$$ email$_ $$$ }'
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (7)
apps/api/.env.example(1 hunks)apps/api/src/routes/subscriptions.ts(4 hunks)apps/api/src/stripe.ts(2 hunks)apps/ui/src/components/billing/plan-management.tsx(2 hunks)apps/ui/src/components/landing/pricing-plans.tsx(1 hunks)apps/ui/src/lib/api/v1.d.ts(1 hunks)apps/ui/src/routes/dashboard/_layout/settings/billing.tsx(2 hunks)
🧰 Additional context used
🧬 Code Graph Analysis (1)
apps/ui/src/components/billing/plan-management.tsx (2)
apps/ui/src/lib/components/use-toast.ts (1)
toast(192-192)apps/ui/src/lib/components/dialog.tsx (1)
DialogFooter(127-127)
⏰ Context from checks skipped due to timeout of 90000ms (1)
- GitHub Check: e2e / run
🔇 Additional comments (3)
apps/ui/src/lib/api/v1.d.ts (1)
2076-2083: LGTM! API type definition correctly reflects the new Stripe Checkout flow.The response schema change from
{clientSecret, subscriptionId}to{checkoutUrl}aligns perfectly with the backend migration to Stripe Checkout sessions.apps/ui/src/routes/dashboard/_layout/settings/billing.tsx (1)
22-25: LGTM! Robust boolean parsing for URL parameters.The validation logic correctly handles both string and boolean values for the search parameters.
apps/api/src/stripe.ts (1)
189-191: LGTM! Proper webhook event handling for checkout sessions.The addition correctly handles the new Stripe Checkout completion event.
| const { checkoutUrl } = await response.json(); | ||
|
|
||
| if (clientSecret) { | ||
| toast({ | ||
| title: "Payment confirmation required", | ||
| description: | ||
| "Please confirm your payment to complete the subscription.", | ||
| }); | ||
| } else { | ||
| toast({ | ||
| title: "Subscription created successfully!", | ||
| description: "Welcome to Pro! Your subscription is now active.", | ||
| }); | ||
| await fetchSubscriptionStatus(); | ||
| navigate({ to: "/dashboard" }); | ||
| } | ||
| // Redirect to Stripe Checkout | ||
| window.location.href = checkoutUrl; |
There was a problem hiding this comment.
Ensure the checkout URL is valid before redirecting.
The code directly redirects to checkoutUrl without validating that it's a non-empty string. Since the API returns an empty string when session.url is null (line 99 in subscriptions.ts), this could lead to redirecting users to the current page.
Apply this diff to add validation:
const { checkoutUrl } = await response.json();
+if (!checkoutUrl) {
+ throw new Error("Invalid checkout URL received from server");
+}
+
// Redirect to Stripe Checkout
window.location.href = checkoutUrl;📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const { checkoutUrl } = await response.json(); | |
| if (clientSecret) { | |
| toast({ | |
| title: "Payment confirmation required", | |
| description: | |
| "Please confirm your payment to complete the subscription.", | |
| }); | |
| } else { | |
| toast({ | |
| title: "Subscription created successfully!", | |
| description: "Welcome to Pro! Your subscription is now active.", | |
| }); | |
| await fetchSubscriptionStatus(); | |
| navigate({ to: "/dashboard" }); | |
| } | |
| // Redirect to Stripe Checkout | |
| window.location.href = checkoutUrl; | |
| const { checkoutUrl } = await response.json(); | |
| if (!checkoutUrl) { | |
| throw new Error("Invalid checkout URL received from server"); | |
| } | |
| // Redirect to Stripe Checkout | |
| window.location.href = checkoutUrl; |
🤖 Prompt for AI Agents
In apps/ui/src/components/landing/pricing-plans.tsx around lines 86 to 89, the
code redirects to checkoutUrl without checking if it is a valid non-empty
string. To fix this, add a validation step before the redirect to ensure
checkoutUrl is not empty or null. Only set window.location.href to checkoutUrl
if it passes this validation; otherwise, handle the error or fallback
appropriately to avoid redirecting users to the current page.
| const { checkoutUrl } = await createSubscriptionMutation.mutateAsync({}); | ||
|
|
||
| if (status === "succeeded") { | ||
| await queryClient.invalidateQueries({ | ||
| queryKey: $api.queryOptions("get", "/subscriptions/status").queryKey, | ||
| }); | ||
| toast({ | ||
| title: "Upgrade Successful", | ||
| description: | ||
| "Welcome to Pro! You may need to wait for a minute and/or refresh the page.", | ||
| }); | ||
| onSuccess(); | ||
| } else if (status === "requires_action") { | ||
| toast({ | ||
| title: "Additional Authentication Required", | ||
| description: "Please complete the additional authentication steps.", | ||
| }); | ||
| } else if (status === "processing") { | ||
| toast({ | ||
| title: "Payment Processing", | ||
| description: | ||
| "Your payment is being processed. Please check later for completion.", | ||
| }); | ||
| } else if (status === "requires_capture") { | ||
| toast({ | ||
| title: "Payment Authorized", | ||
| description: | ||
| "Your payment has been authorized and will be captured soon.", | ||
| }); | ||
| } else { | ||
| toast({ | ||
| title: `Payment Status: ${status}`, | ||
| description: | ||
| "Unable to determine payment status. Please contact support.", | ||
| variant: "destructive", | ||
| className: "text-white", | ||
| }); | ||
| } | ||
| } catch (error: any) { | ||
| // Redirect to Stripe Checkout | ||
| window.location.href = checkoutUrl; |
There was a problem hiding this comment.
Add checkout URL validation to prevent invalid redirects.
Similar to the pricing plans component, the checkout URL should be validated before redirecting to avoid potential issues with empty URLs.
Apply this diff to add validation:
const { checkoutUrl } = await createSubscriptionMutation.mutateAsync({});
+if (!checkoutUrl) {
+ throw new Error("Invalid checkout URL received from server");
+}
+
// Redirect to Stripe Checkout
window.location.href = checkoutUrl;📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const { checkoutUrl } = await createSubscriptionMutation.mutateAsync({}); | |
| if (status === "succeeded") { | |
| await queryClient.invalidateQueries({ | |
| queryKey: $api.queryOptions("get", "/subscriptions/status").queryKey, | |
| }); | |
| toast({ | |
| title: "Upgrade Successful", | |
| description: | |
| "Welcome to Pro! You may need to wait for a minute and/or refresh the page.", | |
| }); | |
| onSuccess(); | |
| } else if (status === "requires_action") { | |
| toast({ | |
| title: "Additional Authentication Required", | |
| description: "Please complete the additional authentication steps.", | |
| }); | |
| } else if (status === "processing") { | |
| toast({ | |
| title: "Payment Processing", | |
| description: | |
| "Your payment is being processed. Please check later for completion.", | |
| }); | |
| } else if (status === "requires_capture") { | |
| toast({ | |
| title: "Payment Authorized", | |
| description: | |
| "Your payment has been authorized and will be captured soon.", | |
| }); | |
| } else { | |
| toast({ | |
| title: `Payment Status: ${status}`, | |
| description: | |
| "Unable to determine payment status. Please contact support.", | |
| variant: "destructive", | |
| className: "text-white", | |
| }); | |
| } | |
| } catch (error: any) { | |
| // Redirect to Stripe Checkout | |
| window.location.href = checkoutUrl; | |
| const { checkoutUrl } = await createSubscriptionMutation.mutateAsync({}); | |
| if (!checkoutUrl) { | |
| throw new Error("Invalid checkout URL received from server"); | |
| } | |
| // Redirect to Stripe Checkout | |
| window.location.href = checkoutUrl; |
🤖 Prompt for AI Agents
In apps/ui/src/components/billing/plan-management.tsx around lines 252 to 255,
the code redirects to the checkoutUrl without validating it, which may cause
invalid redirects if the URL is empty or undefined. Add a check to ensure
checkoutUrl is a non-empty string before assigning it to window.location.href.
If the URL is invalid, avoid redirecting or handle the error appropriately.
There was a problem hiding this comment.
Actionable comments posted: 1
♻️ Duplicate comments (1)
apps/api/src/stripe.ts (1)
288-300: Fix PostHog distinctId to use organization ID.The static
distinctId: "organization"prevents proper event attribution to individual organizations, which was flagged in previous reviews.posthog.capture({ - distinctId: "organization", + distinctId: organizationId, event: "subscription_created", groups: { organization: organizationId, }, properties: { plan: "pro", organization: organizationId, - subscriptionId: subscriptionId, + subscriptionId: typeof subscription === "string" ? subscription : subscription?.id, source: "stripe_checkout", }, });
🧹 Nitpick comments (1)
apps/api/src/stripe.ts (1)
250-262: Fix redundant subscription ID extraction.The subscription ID is extracted twice - once on line 218 and again on lines 251-252.
- const subscriptionId = - typeof subscription === "string" ? subscription : subscription?.id; - const result = await db .update(tables.organization) .set({ plan: "pro", - stripeSubscriptionId: subscriptionId, + stripeSubscriptionId: typeof subscription === "string" ? subscription : subscription?.id, subscriptionCancelled: false, })
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (8)
apps/api/.env.example(1 hunks)apps/api/src/routes/subscriptions.ts(4 hunks)apps/api/src/stripe.ts(2 hunks)apps/ui/src/components/billing/plan-management.tsx(2 hunks)apps/ui/src/components/dashboard/dashboard-sidebar.tsx(1 hunks)apps/ui/src/components/landing/pricing-plans.tsx(1 hunks)apps/ui/src/lib/api/v1.d.ts(1 hunks)apps/ui/src/routes/dashboard/_layout/settings/billing.tsx(2 hunks)
✅ Files skipped from review due to trivial changes (1)
- apps/ui/src/components/dashboard/dashboard-sidebar.tsx
🚧 Files skipped from review as they are similar to previous changes (6)
- apps/api/.env.example
- apps/ui/src/routes/dashboard/_layout/settings/billing.tsx
- apps/api/src/routes/subscriptions.ts
- apps/ui/src/lib/api/v1.d.ts
- apps/ui/src/components/billing/plan-management.tsx
- apps/ui/src/components/landing/pricing-plans.tsx
⏰ Context from checks skipped due to timeout of 90000ms (1)
- GitHub Check: e2e / run
🔇 Additional comments (3)
apps/api/src/stripe.ts (3)
189-191: LGTM! Proper event routing for checkout session completion.The switch case addition correctly routes checkout session completion events to the new handler.
214-227: LGTM! Solid validation and early return pattern.The function properly validates the subscription presence and exits early for non-subscription checkout sessions.
229-241: LGTM! Good use of the organization resolution helper.The function correctly leverages the existing
resolveOrganizationFromStripeEventhelper with proper error handling for cases where the organization cannot be found.
| await db.insert(tables.transaction).values({ | ||
| organizationId, | ||
| type: "subscription_start", | ||
| amount: ((session.amount_total || 0) / 100).toString(), | ||
| currency: (session.currency || "USD").toUpperCase(), | ||
| status: "completed", | ||
| stripeInvoiceId: session.invoice as string, | ||
| description: "Pro subscription started via Stripe Checkout", | ||
| }); |
There was a problem hiding this comment.
Add null safety for session amount and invoice.
The code assumes session.amount_total and session.invoice are non-null, but they could be undefined according to Stripe's API.
await db.insert(tables.transaction).values({
organizationId,
type: "subscription_start",
- amount: ((session.amount_total || 0) / 100).toString(),
+ amount: session.amount_total ? (session.amount_total / 100).toString() : "0",
currency: (session.currency || "USD").toUpperCase(),
status: "completed",
- stripeInvoiceId: session.invoice as string,
+ stripeInvoiceId: session.invoice ? String(session.invoice) : null,
description: "Pro subscription started via Stripe Checkout",
});📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| await db.insert(tables.transaction).values({ | |
| organizationId, | |
| type: "subscription_start", | |
| amount: ((session.amount_total || 0) / 100).toString(), | |
| currency: (session.currency || "USD").toUpperCase(), | |
| status: "completed", | |
| stripeInvoiceId: session.invoice as string, | |
| description: "Pro subscription started via Stripe Checkout", | |
| }); | |
| await db.insert(tables.transaction).values({ | |
| organizationId, | |
| type: "subscription_start", | |
| - amount: ((session.amount_total || 0) / 100).toString(), | |
| + amount: session.amount_total ? (session.amount_total / 100).toString() : "0", | |
| currency: (session.currency || "USD").toUpperCase(), | |
| status: "completed", | |
| - stripeInvoiceId: session.invoice as string, | |
| + stripeInvoiceId: session.invoice ? String(session.invoice) : null, | |
| description: "Pro subscription started via Stripe Checkout", | |
| }); |
🤖 Prompt for AI Agents
In apps/api/src/stripe.ts around lines 270 to 278, the code assumes
session.amount_total and session.invoice are always defined, but Stripe's API
allows them to be undefined. To fix this, add null checks or default values
before using these properties. For amount_total, ensure you handle undefined by
defaulting to 0 or skipping the insert if appropriate. For invoice, verify it is
a non-null string before casting or provide a fallback value to avoid runtime
errors.
Replaced the manual subscription and payment logic with Stripe Checkout. Simplified subscription handling in both API and UI, ensuring a seamless upgrade flow. Added success and cancellation handling in the billing UI.
Replaced the manual subscription and payment logic with Stripe Checkout. Simplified subscription handling in both API and UI, ensuring a seamless upgrade flow. Added success and cancellation handling in the billing UI.
Summary by CodeRabbit
New Features
Enhancements
Bug Fixes
Chores