From cbaf2f3792599c9c7b71eabb9efe6db50d82e0b8 Mon Sep 17 00:00:00 2001 From: Luca Steeb Date: Fri, 6 Jun 2025 17:25:36 +0100 Subject: [PATCH 01/21] feat(schema): add subscriptions and plans support Extended `schema.ts` with `stripeSubscriptionId`, `plan`, and `planExpiresAt` fields for enhanced subscription management. Added `subscriptions` route in `api`. Updated API type definitions to reflect these changes. Improves flexibility in handling user plans and statuses. --- .env.example | 1 + apps/api/.env.example | 1 + apps/api/src/routes/index.ts | 2 + apps/api/src/routes/keys-provider.ts | 10 + apps/api/src/routes/organization.ts | 2 + apps/api/src/routes/subscriptions.ts | 265 +++++++++++++++ apps/api/src/stripe.ts | 130 ++++++++ .../components/billing/plan-management.tsx | 315 ++++++++++++++++++ .../credits/payment-methods-management.tsx | 5 + .../create-provider-key-dialog.tsx | 28 +- .../settings/project-mode-settings.tsx | 51 ++- apps/ui/src/hooks/useOrganization.ts | 2 + apps/ui/src/lib/api/v1.d.ts | 125 +++++++ .../src/routes/dashboard/_layout/settings.tsx | 7 +- packages/db/src/schema.ts | 9 +- 15 files changed, 942 insertions(+), 11 deletions(-) create mode 100644 apps/api/src/routes/subscriptions.ts create mode 100644 apps/ui/src/components/billing/plan-management.tsx diff --git a/.env.example b/.env.example index 2aa6c1d394..9065b7462a 100644 --- a/.env.example +++ b/.env.example @@ -94,3 +94,4 @@ NEXT_PUBLIC_POSTHOG_HOST=https://app.posthog.com STRIPE_SECRET_KEY=sk_test_your_stripe_secret_key STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret +STRIPE_PRO_PRICE_ID=price_your_pro_plan_price_id diff --git a/apps/api/.env.example b/apps/api/.env.example index de226096fd..c70cfdd2df 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -1,5 +1,6 @@ STRIPE_SECRET_KEY=sk_test_123 STRIPE_WEBHOOK_SECRET=whsec_123 +STRIPE_PRO_PRICE_ID=price_12345678 VITE_POSTHOG_KEY=phc_123 VITE_POSTHOG_HOST=https://app.posthog.com diff --git a/apps/api/src/routes/index.ts b/apps/api/src/routes/index.ts index 358c492332..89edf00e11 100644 --- a/apps/api/src/routes/index.ts +++ b/apps/api/src/routes/index.ts @@ -8,6 +8,7 @@ import { logs } from "./logs"; import organization from "./organization"; import { payments } from "./payments"; import projects from "./projects"; +import { subscriptions } from "./subscriptions"; import { user } from "./user"; import type { ServerTypes } from "../vars"; @@ -40,3 +41,4 @@ routes.route("/projects", projects); routes.route("/orgs", organization); routes.route("/payments", payments); +routes.route("/subscriptions", subscriptions); diff --git a/apps/api/src/routes/keys-provider.ts b/apps/api/src/routes/keys-provider.ts index c88830f77b..6fdc92940a 100644 --- a/apps/api/src/routes/keys-provider.ts +++ b/apps/api/src/routes/keys-provider.ts @@ -116,6 +116,16 @@ keysProvider.openapi(create, async (c) => { }); } + const organization = userOrgs[0].organization; + + // Check if organization has pro plan for provider keys + if (organization?.plan !== "pro") { + throw new HTTPException(403, { + message: + "Provider keys are only available on the Pro plan. Please upgrade to use your own API keys.", + }); + } + // Check if a provider key already exists for this provider and organization const existingKey = await db.query.providerKey.findFirst({ where: { diff --git a/apps/api/src/routes/organization.ts b/apps/api/src/routes/organization.ts index 564acac4c2..2d552e4c35 100644 --- a/apps/api/src/routes/organization.ts +++ b/apps/api/src/routes/organization.ts @@ -14,6 +14,8 @@ const organizationSchema = z.object({ updatedAt: z.date(), name: z.string(), credits: z.string(), + plan: z.enum(["free", "pro"]), + planExpiresAt: z.date().nullable(), status: z.enum(["active", "inactive", "deleted"]).nullable(), }); diff --git a/apps/api/src/routes/subscriptions.ts b/apps/api/src/routes/subscriptions.ts new file mode 100644 index 0000000000..be8d61be57 --- /dev/null +++ b/apps/api/src/routes/subscriptions.ts @@ -0,0 +1,265 @@ +import { createRoute, OpenAPIHono } from "@hono/zod-openapi"; +import { db, eq, tables } from "@openllm/db"; +import { HTTPException } from "hono/http-exception"; +import { z } from "zod"; + +import { stripe } from "./payments"; +import { ensureStripeCustomer } from "../stripe"; + +import type { ServerTypes } from "../vars"; + +export const subscriptions = new OpenAPIHono(); + +const createProSubscription = createRoute({ + method: "post", + path: "/create-pro-subscription", + request: {}, + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ + clientSecret: z.string().nullable(), + subscriptionId: z.string(), + }), + }, + }, + description: "Pro subscription created successfully", + }, + }, +}); + +subscriptions.openapi(createProSubscription, async (c) => { + const user = c.get("user"); + + if (!user) { + throw new HTTPException(401, { + message: "Unauthorized", + }); + } + + const userOrganization = await db.query.userOrganization.findFirst({ + where: { + userId: user.id, + }, + with: { + organization: true, + }, + }); + + if (!userOrganization || !userOrganization.organization) { + throw new HTTPException(404, { + message: "Organization not found", + }); + } + + const organization = userOrganization.organization; + + // Check if organization already has a pro subscription + if (organization.plan === "pro" && organization.stripeSubscriptionId) { + throw new HTTPException(400, { + message: "Organization already has an active pro subscription", + }); + } + + // Get the default payment method for this organization + const defaultPaymentMethod = await db.query.paymentMethod.findFirst({ + where: { + organizationId: organization.id, + isDefault: true, + }, + }); + + if (!defaultPaymentMethod) { + throw new HTTPException(400, { + message: + "No default payment method found. Please add a payment method first.", + }); + } + + try { + const stripeCustomerId = await ensureStripeCustomer(organization.id); + + // Create a subscription using the default payment method + const subscription = await stripe.subscriptions.create({ + customer: stripeCustomerId, + items: [ + { + price: process.env.STRIPE_PRO_PRICE_ID || "price_1234567890", + }, + ], + default_payment_method: defaultPaymentMethod.stripePaymentMethodId, + expand: ["latest_invoice.payment_intent"], + metadata: { + organizationId: organization.id, + plan: "pro", + }, + }); + + // Update organization with subscription ID + await db + .update(tables.organization) + .set({ + stripeSubscriptionId: subscription.id, + updatedAt: new Date(), + }) + .where(eq(tables.organization.id, organization.id)); + + const invoice = subscription.latest_invoice as any; + const paymentIntent = invoice?.payment_intent; + + // If payment requires confirmation, return client secret + if (paymentIntent && paymentIntent.status === "requires_confirmation") { + return c.json({ + clientSecret: paymentIntent.client_secret || "", + subscriptionId: subscription.id, + }); + } + + // If payment succeeded immediately, return success + return c.json({ + clientSecret: null, + subscriptionId: subscription.id, + }); + } catch (error) { + console.error("Stripe subscription error:", error); + throw new HTTPException(500, { + message: `Failed to create subscription: ${error}`, + }); + } +}); + +const cancelProSubscription = createRoute({ + method: "post", + path: "/cancel-pro-subscription", + request: {}, + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ + success: z.boolean(), + }), + }, + }, + description: "Pro subscription canceled successfully", + }, + }, +}); + +subscriptions.openapi(cancelProSubscription, async (c) => { + const user = c.get("user"); + + if (!user) { + throw new HTTPException(401, { + message: "Unauthorized", + }); + } + + const userOrganization = await db.query.userOrganization.findFirst({ + where: { + userId: user.id, + }, + with: { + organization: true, + }, + }); + + if (!userOrganization || !userOrganization.organization) { + throw new HTTPException(404, { + message: "Organization not found", + }); + } + + const organization = userOrganization.organization; + + if (!organization.stripeSubscriptionId) { + throw new HTTPException(400, { + message: "No active subscription found", + }); + } + + try { + // Cancel the subscription at the end of the current period + await stripe.subscriptions.update(organization.stripeSubscriptionId, { + cancel_at_period_end: true, + }); + + return c.json({ + success: true, + }); + } catch (error) { + console.error("Stripe subscription cancellation error:", error); + throw new HTTPException(500, { + message: "Failed to cancel subscription", + }); + } +}); + +const getSubscriptionStatus = createRoute({ + method: "get", + path: "/status", + request: {}, + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ + plan: z.enum(["free", "pro"]), + subscriptionId: z.string().nullable(), + planExpiresAt: z.string().nullable(), + cancelAtPeriodEnd: z.boolean().nullable(), + }), + }, + }, + description: "Subscription status retrieved successfully", + }, + }, +}); + +subscriptions.openapi(getSubscriptionStatus, async (c) => { + const user = c.get("user"); + + if (!user) { + throw new HTTPException(401, { + message: "Unauthorized", + }); + } + + const userOrganization = await db.query.userOrganization.findFirst({ + where: { + userId: user.id, + }, + with: { + organization: true, + }, + }); + + if (!userOrganization || !userOrganization.organization) { + throw new HTTPException(404, { + message: "Organization not found", + }); + } + + const organization = userOrganization.organization; + let cancelAtPeriodEnd = null; + + // If there's a subscription, get its status from Stripe + if (organization.stripeSubscriptionId) { + try { + const subscription = await stripe.subscriptions.retrieve( + organization.stripeSubscriptionId, + ); + cancelAtPeriodEnd = subscription.cancel_at_period_end; + } catch (error) { + console.error("Error retrieving subscription:", error); + } + } + + return c.json({ + plan: organization.plan || "free", + subscriptionId: organization.stripeSubscriptionId, + planExpiresAt: organization.planExpiresAt?.toISOString() || null, + cancelAtPeriodEnd, + }); +}); diff --git a/apps/api/src/stripe.ts b/apps/api/src/stripe.ts index 3075f1d4df..3b5991efa4 100644 --- a/apps/api/src/stripe.ts +++ b/apps/api/src/stripe.ts @@ -88,6 +88,15 @@ stripeRoutes.openapi(webhookHandler, async (c) => { case "setup_intent.succeeded": await handleSetupIntentSucceeded(event.data.object); break; + case "invoice.payment_succeeded": + await handleInvoicePaymentSucceeded(event.data.object); + break; + case "customer.subscription.updated": + await handleSubscriptionUpdated(event.data.object); + break; + case "customer.subscription.deleted": + await handleSubscriptionDeleted(event.data.object); + break; default: console.log(`Unhandled event type: ${event.type}`); } @@ -208,3 +217,124 @@ async function handleSetupIntentSucceeded(setupIntent: any) { updatedAt: new Date(), }); } + +async function handleInvoicePaymentSucceeded(invoice: any) { + const { customer, subscription } = invoice; + + if (!subscription) { + return; // Not a subscription invoice + } + + // Try to get organization ID from subscription metadata first + let organizationId = null; + + // Check if we have organizationId in the invoice metadata + if (metadata && metadata.organizationId) { + organizationId = metadata.organizationId; + } else { + // Fallback: get subscription details to find organizationId + try { + const stripeSubscription = + await stripe.subscriptions.retrieve(subscription); + if ( + stripeSubscription.metadata && + stripeSubscription.metadata.organizationId + ) { + organizationId = stripeSubscription.metadata.organizationId; + } + } catch (error) { + console.error("Error retrieving subscription:", error); + } + } + + // If we still don't have organizationId, try to find by customer ID + if (!organizationId) { + const organization = await db.query.organization.findFirst({ + where: { + stripeCustomerId: customer, + }, + }); + + if (organization) { + organizationId = organization.id; + } + } + + if (!organizationId) { + console.error( + `Organization not found for customer: ${customer}, subscription: ${subscription}`, + ); + return; + } + + // Update organization to pro plan + await db + .update(tables.organization) + .set({ + plan: "pro", + updatedAt: new Date(), + }) + .where(eq(tables.organization.id, organizationId)); + + console.log(`Upgraded organization ${organizationId} to pro plan`); +} + +async function handleSubscriptionUpdated(subscription: any) { + const { customer, current_period_end } = subscription; + + // Find organization by Stripe customer ID + const organization = await db.query.organization.findFirst({ + where: { + stripeCustomerId: customer, + }, + }); + + if (!organization) { + console.error(`Organization not found for customer: ${customer}`); + return; + } + + // Update plan expiration date + const planExpiresAt = new Date(current_period_end * 1000); + + await db + .update(tables.organization) + .set({ + planExpiresAt, + updatedAt: new Date(), + }) + .where(eq(tables.organization.id, organization.id)); + + console.log( + `Updated subscription for organization ${organization.id}, expires at: ${planExpiresAt}`, + ); +} + +async function handleSubscriptionDeleted(subscription: any) { + const { customer } = subscription; + + // Find organization by Stripe customer ID + const organization = await db.query.organization.findFirst({ + where: { + stripeCustomerId: customer, + }, + }); + + if (!organization) { + console.error(`Organization not found for customer: ${customer}`); + return; + } + + // Downgrade organization to free plan + await db + .update(tables.organization) + .set({ + plan: "free", + stripeSubscriptionId: null, + planExpiresAt: null, + updatedAt: new Date(), + }) + .where(eq(tables.organization.id, organization.id)); + + console.log(`Downgraded organization ${organization.id} to free plan`); +} diff --git a/apps/ui/src/components/billing/plan-management.tsx b/apps/ui/src/components/billing/plan-management.tsx new file mode 100644 index 0000000000..d04849ab81 --- /dev/null +++ b/apps/ui/src/components/billing/plan-management.tsx @@ -0,0 +1,315 @@ +import { Elements } from "@stripe/react-stripe-js"; +import { useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; + +import { useDefaultOrganization } from "@/hooks/useOrganization"; +import { Badge } from "@/lib/components/badge"; +import { Button } from "@/lib/components/button"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/lib/components/card"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/lib/components/dialog"; +import { useToast } from "@/lib/components/use-toast"; +import { $api } from "@/lib/fetch-client"; +import { useStripe } from "@/lib/stripe"; + +export function PlanManagement() { + const { data: organization } = useDefaultOrganization(); + const { toast } = useToast(); + const [upgradeDialogOpen, setUpgradeDialogOpen] = useState(false); + + const { data: subscriptionStatus } = $api.useQuery( + "get", + "/subscriptions/status", + { + retry: 1, + }, + ); + + const cancelSubscriptionMutation = $api.useMutation( + "post", + "/subscriptions/cancel-pro-subscription", + ); + + const handleCancelSubscription = async () => { + const confirmed = window.confirm( + "Are you sure you want to cancel your Pro subscription? You'll lose access to provider keys at the end of your billing period.", + ); + + if (!confirmed) { + return; + } + + try { + await cancelSubscriptionMutation.mutateAsync({}); + toast({ + title: "Subscription Canceled", + description: + "Your Pro subscription has been canceled. You'll retain access until the end of your billing period.", + }); + } catch (error) { + toast({ + title: "Error", + description: `Failed to cancel subscription. Please try again. Error: ${error}`, + variant: "destructive", + }); + } + }; + + if (!organization) { + return ( + + + Plan & Billing + Loading plan information... + + + ); + } + + const isProPlan = organization.plan === "pro"; + const planExpiresAt = organization.planExpiresAt + ? new Date(organization.planExpiresAt) + : null; + + return ( + + + Plan & Billing + + Manage your subscription and billing preferences + + + +
+
+
+

Current Plan

+ + {isProPlan ? "Pro" : "Free"} + +
+

+ {isProPlan + ? "Access to provider keys and all features" + : "Limited to credits-based usage only"} +

+ {planExpiresAt && ( +

+ {subscriptionStatus?.cancelAtPeriodEnd + ? `Expires on ${planExpiresAt.toLocaleDateString()}` + : `Renews on ${planExpiresAt.toLocaleDateString()}`} +

+ )} +
+
+

+ {isProPlan ? "$50" : "$0"} + + /month + +

+
+
+ +
+

Plan Features

+
+
+
+
+ Provider API Keys + {!isProPlan && ( + + Pro Only + + )} +
+
+
+ Credits System +
+
+
+
+
+ API Access +
+
+
+ Usage Analytics +
+
+
+
+ + + {!isProPlan ? ( + + + + + + setUpgradeDialogOpen(false)} /> + + + ) : ( +
+ {!subscriptionStatus?.cancelAtPeriodEnd && ( + + )} + {subscriptionStatus?.cancelAtPeriodEnd && ( + Subscription Canceled + )} +
+ )} +
+ + ); +} + +function UpgradeDialog({ onSuccess }: { onSuccess: () => void }) { + const queryClient = useQueryClient(); + const { stripe, isLoading: stripeLoading } = useStripe(); + const { toast } = useToast(); + const [loading, setLoading] = useState(false); + + const createSubscriptionMutation = $api.useMutation( + "post", + "/subscriptions/create-pro-subscription", + ); + + const handleUpgrade = async () => { + if (!stripe) { + return; + } + + setLoading(true); + + try { + const { clientSecret } = await createSubscriptionMutation.mutateAsync({}); + + // If no client secret, payment succeeded immediately + if (!clientSecret) { + await queryClient.invalidateQueries({ + queryKey: $api.queryOptions("get", "/subscriptions/status").queryKey, + }); + toast({ + title: "Upgrade Successful", + description: "Welcome to Pro!", + }); + onSuccess(); + return; + } + + // Otherwise, confirm the payment + const result = await stripe.confirmCardPayment(clientSecret); + + if (result.error) { + toast({ + title: "Payment Failed", + description: result.error.message, + variant: "destructive", + className: "text-white", + }); + } else { + await queryClient.invalidateQueries({ + queryKey: $api.queryOptions("get", "/subscriptions/status").queryKey, + }); + toast({ + title: "Upgrade Successful", + description: "Welcome to Pro!", + }); + onSuccess(); + } + } catch (error: any) { + toast({ + title: "Error", + description: + error?.message || "Failed to process upgrade. Please try again.", + variant: "destructive", + className: "text-white", + }); + } finally { + setLoading(false); + } + }; + + if (stripeLoading) { + return ( +
+

Loading payment form...

+
+ ); + } + + return ( + + + Upgrade to Pro + + Unlock provider keys and get full access to all features for + $50/month. + + +
+
+

What you'll get:

+
    +
  • +
    + Use your own OpenAI, Anthropic, and other provider API keys +
  • +
  • +
    + Hybrid mode: fallback to credits when needed +
  • +
  • +
    + No surcharges or fees for API keys or credits usage +
  • +
  • +
    + All existing features (credits, analytics, etc.) +
  • +
+
+
+ + + +
+ ); +} diff --git a/apps/ui/src/components/credits/payment-methods-management.tsx b/apps/ui/src/components/credits/payment-methods-management.tsx index 7a8c46e28a..7098ea0369 100644 --- a/apps/ui/src/components/credits/payment-methods-management.tsx +++ b/apps/ui/src/components/credits/payment-methods-management.tsx @@ -189,6 +189,7 @@ function AddPaymentMethodDialog() { } function AddPaymentMethodForm({ onSuccess }: { onSuccess: () => void }) { + const queryClient = useQueryClient(); const stripe = useStripeElements(); const elements = useElements(); const [loading, setLoading] = useState(false); @@ -223,6 +224,10 @@ function AddPaymentMethodForm({ onSuccess }: { onSuccess: () => void }) { variant: "destructive", }); } else { + await queryClient.invalidateQueries({ + queryKey: $api.queryOptions("get", "/payments/payment-methods") + .queryKey, + }); toast({ title: "Success", description: "Payment method added successfully", diff --git a/apps/ui/src/components/provider-keys/create-provider-key-dialog.tsx b/apps/ui/src/components/provider-keys/create-provider-key-dialog.tsx index 90ff02f08e..5723dd2180 100644 --- a/apps/ui/src/components/provider-keys/create-provider-key-dialog.tsx +++ b/apps/ui/src/components/provider-keys/create-provider-key-dialog.tsx @@ -12,6 +12,8 @@ import OpenAiLogo from "@/assets/models/openai.svg?react"; import OpenLLMLogo from "@/assets/models/openllm.svg?react"; import TogetherAiLogo from "@/assets/models/together-ai.svg?react"; import { useDefaultOrganization } from "@/hooks/useOrganization"; +import { Alert, AlertDescription } from "@/lib/components/alert"; +import { Badge } from "@/lib/components/badge"; import { Button } from "@/lib/components/button"; import { Dialog, @@ -67,6 +69,8 @@ export function CreateProviderKeyDialog({ const { data: providerKeysData, isPending: isLoading } = $api.useSuspenseQuery("get", "/keys/provider"); + const isProPlan = organization?.plan === "pro"; + const createMutation = $api.useMutation("post", "/keys/provider"); const availableProviders = providers.filter((provider) => { @@ -87,6 +91,17 @@ export function CreateProviderKeyDialog({ const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); + if (!isProPlan) { + toast({ + title: "Upgrade Required", + description: + "Provider keys are only available on the Pro plan. Please upgrade to use your own API keys.", + variant: "destructive", + className: "text-white", + }); + return; + } + if (!selectedProvider || !token) { toast({ title: "Error", @@ -184,6 +199,14 @@ export function CreateProviderKeyDialog({ Create a new provider key to connect to an LLM provider. + {!isProPlan && ( + + + Provider keys are only available on the Pro plan. + Pro Only + + + )}
@@ -254,7 +277,10 @@ export function CreateProviderKeyDialog({ )} {subscriptionStatus?.cancelAtPeriodEnd && ( - Subscription Canceled +
+ Subscription Canceled + +
)}
)} diff --git a/apps/ui/src/lib/api/v1.d.ts b/apps/ui/src/lib/api/v1.d.ts index c6ba075fb5..a6e23cd7a1 100644 --- a/apps/ui/src/lib/api/v1.d.ts +++ b/apps/ui/src/lib/api/v1.d.ts @@ -1715,6 +1715,43 @@ export interface paths { patch?: never; trace?: never; }; + "/subscriptions/resume-pro-subscription": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Pro subscription resumed successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + success: boolean; + }; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/subscriptions/status": { parameters: { query?: never; From 93c2eb1be133ef87a37b884eac42007e6e76c7b0 Mon Sep 17 00:00:00 2001 From: Luca Steeb Date: Fri, 6 Jun 2025 19:32:30 +0100 Subject: [PATCH 09/21] feat(api): add strict Stripe event type checking Replaced `any` with specific Stripe event type annotations in event handlers. Updated functions to destructure `event.data.object` for relevant fields. Improves type safety and readability in Stripe webhook processing. --- apps/api/src/stripe.ts | 46 +++++++++++++++++++++++++++++++++--------- 1 file changed, 36 insertions(+), 10 deletions(-) diff --git a/apps/api/src/stripe.ts b/apps/api/src/stripe.ts index 87756e0259..1ab7f294c0 100644 --- a/apps/api/src/stripe.ts +++ b/apps/api/src/stripe.ts @@ -7,6 +7,7 @@ import { posthog } from "./posthog"; import { stripe } from "./routes/payments"; import type { ServerTypes } from "./vars"; +import type Stripe from "stripe"; export async function ensureStripeCustomer( organizationId: string, @@ -178,21 +179,31 @@ stripeRoutes.openapi(webhookHandler, async (c) => { switch (event.type) { case "payment_intent.succeeded": - await handlePaymentIntentSucceeded(event.data.object); + await handlePaymentIntentSucceeded( + event as Stripe.PaymentIntentSucceededEvent, + ); break; case "payment_intent.payment_failed": break; case "setup_intent.succeeded": - await handleSetupIntentSucceeded(event.data.object); + await handleSetupIntentSucceeded( + event as Stripe.SetupIntentSucceededEvent, + ); break; case "invoice.payment_succeeded": - await handleInvoicePaymentSucceeded(event.data.object); + await handleInvoicePaymentSucceeded( + event as Stripe.InvoicePaymentSucceededEvent, + ); break; case "customer.subscription.updated": - await handleSubscriptionUpdated(event.data.object); + await handleSubscriptionUpdated( + event as Stripe.CustomerSubscriptionUpdatedEvent, + ); break; case "customer.subscription.deleted": - await handleSubscriptionDeleted(event.data.object); + await handleSubscriptionDeleted( + event as Stripe.CustomerSubscriptionDeletedEvent, + ); break; default: console.log(`Unhandled event type: ${event.type}`); @@ -207,7 +218,10 @@ stripeRoutes.openapi(webhookHandler, async (c) => { } }); -async function handlePaymentIntentSucceeded(paymentIntent: any) { +async function handlePaymentIntentSucceeded( + event: Stripe.PaymentIntentSucceededEvent, +) { + const paymentIntent = event.data.object; const { metadata, amount } = paymentIntent; const result = await resolveOrganizationFromStripeEvent({ @@ -268,7 +282,10 @@ async function handlePaymentIntentSucceeded(paymentIntent: any) { ); } -async function handleSetupIntentSucceeded(setupIntent: any) { +async function handleSetupIntentSucceeded( + event: Stripe.SetupIntentSucceededEvent, +) { + const setupIntent = event.data.object; const { metadata, payment_method } = setupIntent; const { organizationId } = metadata; @@ -309,7 +326,10 @@ async function handleSetupIntentSucceeded(setupIntent: any) { }); } -async function handleInvoicePaymentSucceeded(invoice: any) { +async function handleInvoicePaymentSucceeded( + event: Stripe.InvoicePaymentSucceededEvent, +) { + const invoice = event.data.object; const { customer, subscription, metadata } = invoice; // Extract subscription ID from line items if not directly available @@ -416,7 +436,10 @@ async function handleInvoicePaymentSucceeded(invoice: any) { } } -async function handleSubscriptionUpdated(subscription: any) { +async function handleSubscriptionUpdated( + event: Stripe.CustomerSubscriptionUpdatedEvent, +) { + const subscription = event.data.object; const { customer, current_period_end, metadata } = subscription; const result = await resolveOrganizationFromStripeEvent({ @@ -447,7 +470,10 @@ async function handleSubscriptionUpdated(subscription: any) { ); } -async function handleSubscriptionDeleted(subscription: any) { +async function handleSubscriptionDeleted( + event: Stripe.CustomerSubscriptionDeletedEvent, +) { + const subscription = event.data.object; const { customer, metadata } = subscription; const result = await resolveOrganizationFromStripeEvent({ From 1a3c016a996ff815f613aecfc964a1cf8ecddce6 Mon Sep 17 00:00:00 2001 From: Luca Steeb Date: Fri, 6 Jun 2025 20:54:47 +0100 Subject: [PATCH 10/21] fix(api): handle optional fields in Stripe event data Ensured safe handling of optional fields like `metadata` and `subscription` in Stripe event data. Converted `payment_method` and `customer` to consistent string identifiers. Improves robustness and error handling in webhook processing. --- apps/api/src/stripe.ts | 52 +++++++++++++++++++----------------------- 1 file changed, 24 insertions(+), 28 deletions(-) diff --git a/apps/api/src/stripe.ts b/apps/api/src/stripe.ts index 1ab7f294c0..77eca054c2 100644 --- a/apps/api/src/stripe.ts +++ b/apps/api/src/stripe.ts @@ -179,31 +179,21 @@ stripeRoutes.openapi(webhookHandler, async (c) => { switch (event.type) { case "payment_intent.succeeded": - await handlePaymentIntentSucceeded( - event as Stripe.PaymentIntentSucceededEvent, - ); + await handlePaymentIntentSucceeded(event); break; case "payment_intent.payment_failed": break; case "setup_intent.succeeded": - await handleSetupIntentSucceeded( - event as Stripe.SetupIntentSucceededEvent, - ); + await handleSetupIntentSucceeded(event); break; case "invoice.payment_succeeded": - await handleInvoicePaymentSucceeded( - event as Stripe.InvoicePaymentSucceededEvent, - ); + await handleInvoicePaymentSucceeded(event); break; case "customer.subscription.updated": - await handleSubscriptionUpdated( - event as Stripe.CustomerSubscriptionUpdatedEvent, - ); + await handleSubscriptionUpdated(event); break; case "customer.subscription.deleted": - await handleSubscriptionDeleted( - event as Stripe.CustomerSubscriptionDeletedEvent, - ); + await handleSubscriptionDeleted(event); break; default: console.log(`Unhandled event type: ${event.type}`); @@ -287,7 +277,7 @@ async function handleSetupIntentSucceeded( ) { const setupIntent = event.data.object; const { metadata, payment_method } = setupIntent; - const { organizationId } = metadata; + const organizationId = metadata?.organizationId; if (!organizationId || !payment_method) { console.error("Missing organizationId or payment_method in setupIntent"); @@ -302,11 +292,14 @@ async function handleSetupIntentSucceeded( return; } - await stripe.paymentMethods.attach(payment_method, { + const paymentMethodId = + typeof payment_method === "string" ? payment_method : payment_method.id; + + await stripe.paymentMethods.attach(paymentMethodId, { customer: stripeCustomerId, }); - const paymentMethod = await stripe.paymentMethods.retrieve(payment_method); + const paymentMethod = await stripe.paymentMethods.retrieve(paymentMethodId); const existingPaymentMethods = await db.query.paymentMethod.findMany({ where: { @@ -317,7 +310,7 @@ async function handleSetupIntentSucceeded( const isDefault = existingPaymentMethods.length === 0; await db.insert(tables.paymentMethod).values({ - stripePaymentMethodId: payment_method, + stripePaymentMethodId: paymentMethodId, organizationId, type: paymentMethod.type, isDefault, @@ -330,10 +323,12 @@ async function handleInvoicePaymentSucceeded( event: Stripe.InvoicePaymentSucceededEvent, ) { const invoice = event.data.object; - const { customer, subscription, metadata } = invoice; + const { customer, metadata } = invoice; + const subscription = (invoice as any).subscription; // Extract subscription ID from line items if not directly available - let subscriptionId = subscription; + let subscriptionId = + typeof subscription === "string" ? subscription : subscription?.id; if ( !subscriptionId && invoice.lines && @@ -360,8 +355,8 @@ async function handleInvoicePaymentSucceeded( } const result = await resolveOrganizationFromStripeEvent({ - metadata, - customer, + metadata: metadata as { organizationId?: string } | undefined, + customer: typeof customer === "string" ? customer : customer?.id, subscription: subscriptionId, lines: invoice.lines, }); @@ -440,11 +435,12 @@ async function handleSubscriptionUpdated( event: Stripe.CustomerSubscriptionUpdatedEvent, ) { const subscription = event.data.object; - const { customer, current_period_end, metadata } = subscription; + const { customer, metadata } = subscription; + const current_period_end = (subscription as any).current_period_end; const result = await resolveOrganizationFromStripeEvent({ - metadata, - customer, + metadata: metadata as { organizationId?: string } | undefined, + customer: typeof customer === "string" ? customer : customer?.id, }); if (!result) { @@ -477,8 +473,8 @@ async function handleSubscriptionDeleted( const { customer, metadata } = subscription; const result = await resolveOrganizationFromStripeEvent({ - metadata, - customer, + metadata: metadata as { organizationId?: string } | undefined, + customer: typeof customer === "string" ? customer : customer?.id, }); if (!result) { From 775528539f90de801308b123244e94edfc9f79aa Mon Sep 17 00:00:00 2001 From: Luca Steeb Date: Fri, 6 Jun 2025 21:12:48 +0100 Subject: [PATCH 11/21] fix(api): handle undefined period end in Stripe events Added checks for `current_period_end` to avoid accessing undefined fields in subscription objects. Improved robustness in Stripe event processing. Updated UI to use `toDateString` for date display in plan management. --- apps/api/src/stripe.ts | 11 +++++++++-- apps/ui/src/components/billing/plan-management.tsx | 4 ++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/apps/api/src/stripe.ts b/apps/api/src/stripe.ts index 77eca054c2..088ba40dae 100644 --- a/apps/api/src/stripe.ts +++ b/apps/api/src/stripe.ts @@ -436,11 +436,16 @@ async function handleSubscriptionUpdated( ) { const subscription = event.data.object; const { customer, metadata } = subscription; - const current_period_end = (subscription as any).current_period_end; + + const current_period_end = + subscription.items.data.length > 0 + ? subscription.items.data[0].current_period_end + : undefined; const result = await resolveOrganizationFromStripeEvent({ metadata: metadata as { organizationId?: string } | undefined, customer: typeof customer === "string" ? customer : customer?.id, + subscription: subscription.id, }); if (!result) { @@ -451,7 +456,9 @@ async function handleSubscriptionUpdated( const { organizationId } = result; // Update plan expiration date - const planExpiresAt = new Date(current_period_end * 1000); + const planExpiresAt = current_period_end + ? new Date(current_period_end * 1000) + : undefined; await db .update(tables.organization) diff --git a/apps/ui/src/components/billing/plan-management.tsx b/apps/ui/src/components/billing/plan-management.tsx index 159d6790f3..e190adf6e5 100644 --- a/apps/ui/src/components/billing/plan-management.tsx +++ b/apps/ui/src/components/billing/plan-management.tsx @@ -144,8 +144,8 @@ export function PlanManagement() { {planExpiresAt && (

{subscriptionStatus?.cancelAtPeriodEnd - ? `Expires on ${planExpiresAt.toLocaleDateString()}` - : `Renews on ${planExpiresAt.toLocaleDateString()}`} + ? `Expires on ${planExpiresAt.toDateString()}` + : `Renews on ${planExpiresAt.toDateString()}`}

)}
From 80974108b5ebf78e8cf948fb56d24a149c19808e Mon Sep 17 00:00:00 2001 From: Luca Steeb Date: Fri, 6 Jun 2025 21:24:54 +0100 Subject: [PATCH 12/21] feat(api): track subscription cancellation state Added `subscriptionCancelled` field to the organization schema. Updated Stripe event handlers to track subscription status changes, ensuring accurate updates for cancellations and reactivations. Integrated PostHog tracking for subscription reactivation events. --- apps/api/src/routes/subscriptions.ts | 5 +++- apps/api/src/stripe.ts | 39 +++++++++++++++++++++++++--- packages/db/src/schema.ts | 1 + 3 files changed, 40 insertions(+), 5 deletions(-) diff --git a/apps/api/src/routes/subscriptions.ts b/apps/api/src/routes/subscriptions.ts index c71716fd35..37aaee76e4 100644 --- a/apps/api/src/routes/subscriptions.ts +++ b/apps/api/src/routes/subscriptions.ts @@ -96,11 +96,12 @@ subscriptions.openapi(createProSubscription, async (c) => { }, }); - // Update organization with subscription ID + // Update organization with subscription ID and mark as not cancelled await db .update(tables.organization) .set({ stripeSubscriptionId: subscription.id, + subscriptionCancelled: false, updatedAt: new Date(), }) .where(eq(tables.organization.id, organization.id)); @@ -287,6 +288,7 @@ const getSubscriptionStatus = createRoute({ subscriptionId: z.string().nullable(), planExpiresAt: z.string().nullable(), cancelAtPeriodEnd: z.boolean().nullable(), + subscriptionCancelled: z.boolean(), }), }, }, @@ -339,5 +341,6 @@ subscriptions.openapi(getSubscriptionStatus, async (c) => { subscriptionId: organization.stripeSubscriptionId, planExpiresAt: organization.planExpiresAt?.toISOString() || null, cancelAtPeriodEnd, + subscriptionCancelled: organization.subscriptionCancelled || false, }); }); diff --git a/apps/api/src/stripe.ts b/apps/api/src/stripe.ts index 088ba40dae..0145003eae 100644 --- a/apps/api/src/stripe.ts +++ b/apps/api/src/stripe.ts @@ -374,12 +374,13 @@ async function handleInvoicePaymentSucceeded( `Found organization: ${organization.name} (${organization.id}), current plan: ${organization.plan}`, ); - // Update organization to pro plan + // Update organization to pro plan and mark subscription as not cancelled try { const result = await db .update(tables.organization) .set({ plan: "pro", + subscriptionCancelled: false, updatedAt: new Date(), }) .where(eq(tables.organization.id, organizationId)) @@ -453,23 +454,52 @@ async function handleSubscriptionUpdated( return; } - const { organizationId } = result; + const { organizationId, organization } = result; // Update plan expiration date const planExpiresAt = current_period_end ? new Date(current_period_end * 1000) : undefined; + // Check if subscription is active and organization was previously cancelled + const isSubscriptionActive = subscription.status === "active"; + const wasSubscriptionCancelled = organization.subscriptionCancelled; + await db .update(tables.organization) .set({ planExpiresAt, + subscriptionCancelled: !isSubscriptionActive, updatedAt: new Date(), }) .where(eq(tables.organization.id, organizationId)); + // Track subscription reactivation if it was previously cancelled and is now active + if (isSubscriptionActive && wasSubscriptionCancelled) { + posthog.groupIdentify({ + groupType: "organization", + groupKey: organizationId, + properties: { + name: organization.name, + }, + }); + posthog.capture({ + distinctId: "organization", + event: "subscription_reactivated", + groups: { + organization: organizationId, + }, + properties: { + plan: "pro", + organization: organizationId, + source: "stripe_subscription_updated", + }, + }); + console.log(`Reactivated subscription for organization ${organizationId}`); + } + console.log( - `Updated subscription for organization ${organizationId}, expires at: ${planExpiresAt}`, + `Updated subscription for organization ${organizationId}, expires at: ${planExpiresAt}, cancelled: ${!isSubscriptionActive}`, ); } @@ -491,13 +521,14 @@ async function handleSubscriptionDeleted( const { organizationId } = result; - // Downgrade organization to free plan + // Downgrade organization to free plan and mark subscription as cancelled await db .update(tables.organization) .set({ plan: "free", stripeSubscriptionId: null, planExpiresAt: null, + subscriptionCancelled: true, updatedAt: new Date(), }) .where(eq(tables.organization.id, organizationId)); diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 58ff1dd12a..8a9550807c 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -108,6 +108,7 @@ export const organization = pgTable("organization", { .notNull() .default("free"), planExpiresAt: timestamp(), + subscriptionCancelled: boolean().notNull().default(false), status: text({ enum: ["active", "inactive", "deleted"], }).default("active"), From 73c915fad979142418d19d11bb1d8c74c3ee30a7 Mon Sep 17 00:00:00 2001 From: Luca Steeb Date: Fri, 6 Jun 2025 21:59:38 +0100 Subject: [PATCH 13/21] feat(api): remove cancelAtPeriodEnd and track cancellation status Replaced `cancelAtPeriodEnd` with `subscriptionCancelled` in the schema. Updated subscription cancellation logic, UI components, and database migration. Refactored API routes for accurate subscription status management. --- apps/api/src/routes/subscriptions.ts | 24 +- .../components/billing/plan-management.tsx | 12 +- apps/ui/src/lib/api/v1.d.ts | 2 +- .../migrations/1749243276_parallel_ultron.sql | 1 + .../migrations/meta/1749243276_snapshot.json | 1246 +++++++++++++++++ packages/db/migrations/meta/_journal.json | 7 + 6 files changed, 1270 insertions(+), 22 deletions(-) create mode 100644 packages/db/migrations/1749243276_parallel_ultron.sql create mode 100644 packages/db/migrations/meta/1749243276_snapshot.json diff --git a/apps/api/src/routes/subscriptions.ts b/apps/api/src/routes/subscriptions.ts index 37aaee76e4..b5115a4871 100644 --- a/apps/api/src/routes/subscriptions.ts +++ b/apps/api/src/routes/subscriptions.ts @@ -186,6 +186,15 @@ subscriptions.openapi(cancelProSubscription, async (c) => { cancel_at_period_end: true, }); + // Update organization to mark subscription as cancelled + await db + .update(tables.organization) + .set({ + subscriptionCancelled: true, + updatedAt: new Date(), + }) + .where(eq(tables.organization.id, organization.id)); + return c.json({ success: true, }); @@ -287,7 +296,6 @@ const getSubscriptionStatus = createRoute({ plan: z.enum(["free", "pro"]), subscriptionId: z.string().nullable(), planExpiresAt: z.string().nullable(), - cancelAtPeriodEnd: z.boolean().nullable(), subscriptionCancelled: z.boolean(), }), }, @@ -322,25 +330,11 @@ subscriptions.openapi(getSubscriptionStatus, async (c) => { } const organization = userOrganization.organization; - let cancelAtPeriodEnd = null; - - // If there's a subscription, get its status from Stripe - if (organization.stripeSubscriptionId) { - try { - const subscription = await stripe.subscriptions.retrieve( - organization.stripeSubscriptionId, - ); - cancelAtPeriodEnd = subscription.cancel_at_period_end; - } catch (error) { - console.error("Error retrieving subscription:", error); - } - } return c.json({ plan: organization.plan || "free", subscriptionId: organization.stripeSubscriptionId, planExpiresAt: organization.planExpiresAt?.toISOString() || null, - cancelAtPeriodEnd, subscriptionCancelled: organization.subscriptionCancelled || false, }); }); diff --git a/apps/ui/src/components/billing/plan-management.tsx b/apps/ui/src/components/billing/plan-management.tsx index e190adf6e5..dce9aa7831 100644 --- a/apps/ui/src/components/billing/plan-management.tsx +++ b/apps/ui/src/components/billing/plan-management.tsx @@ -35,9 +35,6 @@ export function PlanManagement() { const { data: subscriptionStatus } = $api.useQuery( "get", "/subscriptions/status", - { - retry: 1, - }, ); const cancelSubscriptionMutation = $api.useMutation( @@ -61,6 +58,9 @@ export function PlanManagement() { try { await cancelSubscriptionMutation.mutateAsync({}); + await queryClient.invalidateQueries({ + queryKey: $api.queryOptions("get", "/subscriptions/status").queryKey, + }); toast({ title: "Subscription Canceled", description: @@ -143,7 +143,7 @@ export function PlanManagement() {

{planExpiresAt && (

- {subscriptionStatus?.cancelAtPeriodEnd + {subscriptionStatus?.subscriptionCancelled ? `Expires on ${planExpiresAt.toDateString()}` : `Renews on ${planExpiresAt.toDateString()}`}

@@ -206,7 +206,7 @@ export function PlanManagement() { ) : (
- {!subscriptionStatus?.cancelAtPeriodEnd && ( + {!subscriptionStatus?.subscriptionCancelled && (