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/activity.spec.ts b/apps/api/src/routes/activity.spec.ts index 233c999348..8b7e7c7b32 100644 --- a/apps/api/src/routes/activity.spec.ts +++ b/apps/api/src/routes/activity.spec.ts @@ -66,6 +66,8 @@ describe("activity endpoint", () => { completionTokens: "20", totalTokens: "30", messages: JSON.stringify([{ role: "user", content: "Hello" }]), + mode: "api-keys", + usedMode: "api-keys", }, { id: "log-2", @@ -85,6 +87,8 @@ describe("activity endpoint", () => { completionTokens: "15", totalTokens: "20", messages: JSON.stringify([{ role: "user", content: "Hi" }]), + mode: "api-keys", + usedMode: "api-keys", }, { id: "log-3", @@ -104,6 +108,8 @@ describe("activity endpoint", () => { completionTokens: "25", totalTokens: "40", messages: JSON.stringify([{ role: "user", content: "Test" }]), + mode: "api-keys", + usedMode: "api-keys", }, { id: "log-4", @@ -123,6 +129,8 @@ describe("activity endpoint", () => { completionTokens: "18", totalTokens: "26", messages: JSON.stringify([{ role: "user", content: "Query" }]), + mode: "api-keys", + usedMode: "api-keys", }, ]); }); 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.e2e.ts b/apps/api/src/routes/keys-provider.e2e.ts index 0f1d0d6a76..f1de5dcba6 100644 --- a/apps/api/src/routes/keys-provider.e2e.ts +++ b/apps/api/src/routes/keys-provider.e2e.ts @@ -20,6 +20,7 @@ describe("e2e tests for provider keys", () => { await db.insert(tables.organization).values({ id: "test-org-id", name: "Test Organization", + plan: "pro", }); await db.insert(tables.userOrganization).values({ @@ -32,6 +33,7 @@ describe("e2e tests for provider keys", () => { id: "test-project-id", name: "Test Project", organizationId: "test-org-id", + mode: "api-keys", }); }); diff --git a/apps/api/src/routes/keys-provider.spec.ts b/apps/api/src/routes/keys-provider.spec.ts index 5e400fe420..e16bba5886 100644 --- a/apps/api/src/routes/keys-provider.spec.ts +++ b/apps/api/src/routes/keys-provider.spec.ts @@ -18,6 +18,7 @@ describe("provider keys route", () => { await db.insert(tables.organization).values({ id: "test-org-id", name: "Test Organization", + plan: "pro", }); // Associate user with organization 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/logs.spec.ts b/apps/api/src/routes/logs.spec.ts index 526d23a8aa..dc40e4fb12 100644 --- a/apps/api/src/routes/logs.spec.ts +++ b/apps/api/src/routes/logs.spec.ts @@ -108,6 +108,8 @@ describe("logs route", () => { temperature: 0.7, maxTokens: 100, messages: JSON.stringify([{ role: "user", content: "Hello" }]), + mode: "api-keys", + usedMode: "api-keys", }, { id: "test-log-id-2", @@ -129,6 +131,8 @@ describe("logs route", () => { temperature: 0.8, maxTokens: 200, messages: JSON.stringify([{ role: "user", content: "Hello 2" }]), + mode: "api-keys", + usedMode: "api-keys", }, ]); }); @@ -212,7 +216,9 @@ describe("logs route", () => { temperature: 0.7, maxTokens: 100, messages: JSON.stringify([{ role: "user", content: `Hello ${i}` }]), - }); + mode: "api-keys", + usedMode: "api-keys", + } as const); } await db.insert(tables.log).values(additionalLogs); }); 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/payments.ts b/apps/api/src/routes/payments.ts index 5105a5d934..bbbd1284e3 100644 --- a/apps/api/src/routes/payments.ts +++ b/apps/api/src/routes/payments.ts @@ -330,7 +330,6 @@ payments.openapi(setDefaultPaymentMethod, async (c) => { .update(tables.paymentMethod) .set({ isDefault: false, - updatedAt: new Date(), }) .where(eq(tables.paymentMethod.organizationId, organizationId)); @@ -338,7 +337,6 @@ payments.openapi(setDefaultPaymentMethod, async (c) => { .update(tables.paymentMethod) .set({ isDefault: true, - updatedAt: new Date(), }) .where(eq(tables.paymentMethod.id, paymentMethodId)); diff --git a/apps/api/src/routes/subscriptions.ts b/apps/api/src/routes/subscriptions.ts new file mode 100644 index 0000000000..e8df9b7a61 --- /dev/null +++ b/apps/api/src/routes/subscriptions.ts @@ -0,0 +1,353 @@ +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); + + // Check if STRIPE_PRO_PRICE_ID is set + if (!process.env.STRIPE_PRO_PRICE_ID) { + throw new HTTPException(500, { + message: "STRIPE_PRO_PRICE_ID environment variable is not set", + }); + } + + // Create a subscription using the default payment method + const subscription = await stripe.subscriptions.create({ + customer: stripeCustomerId, + items: [ + { + price: process.env.STRIPE_PRO_PRICE_ID, + }, + ], + default_payment_method: defaultPaymentMethod.stripePaymentMethodId, + expand: ["latest_invoice.payment_intent"], + metadata: { + organizationId: organization.id, + plan: "pro", + }, + }); + + // Update organization with subscription ID and mark as not cancelled + await db + .update(tables.organization) + .set({ + stripeSubscriptionId: subscription.id, + subscriptionCancelled: false, + }) + .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, + }); + + // Update organization to mark subscription as cancelled + await db + .update(tables.organization) + .set({ + subscriptionCancelled: true, + }) + .where(eq(tables.organization.id, organization.id)); + + return c.json({ + success: true, + }); + } catch (error) { + console.error("Stripe subscription cancellation error:", error); + throw new HTTPException(500, { + message: "Failed to cancel subscription", + }); + } +}); + +const resumeProSubscription = createRoute({ + method: "post", + path: "/resume-pro-subscription", + request: {}, + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ + success: z.boolean(), + }), + }, + }, + description: "Pro subscription resumed successfully", + }, + }, +}); + +subscriptions.openapi(resumeProSubscription, 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 { + // Check if subscription is actually cancelled + const subscription = await stripe.subscriptions.retrieve( + organization.stripeSubscriptionId, + ); + + if (!subscription.cancel_at_period_end) { + throw new HTTPException(400, { + message: "Subscription is not cancelled", + }); + } + + // Resume the subscription by setting cancel_at_period_end to false + await stripe.subscriptions.update(organization.stripeSubscriptionId, { + cancel_at_period_end: false, + }); + + // Update organization to mark subscription as not cancelled + await db + .update(tables.organization) + .set({ + subscriptionCancelled: false, + }) + .where(eq(tables.organization.id, organization.id)); + + return c.json({ + success: true, + }); + } catch (error) { + console.error("Stripe subscription resume error:", error); + throw new HTTPException(500, { + message: "Failed to resume 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(), + subscriptionCancelled: z.boolean(), + }), + }, + }, + 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; + + return c.json({ + plan: organization.plan || "free", + subscriptionId: organization.stripeSubscriptionId, + planExpiresAt: organization.planExpiresAt?.toISOString() || null, + subscriptionCancelled: organization.subscriptionCancelled || false, + }); +}); diff --git a/apps/api/src/stripe.ts b/apps/api/src/stripe.ts index 3075f1d4df..640075344f 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, @@ -34,7 +35,6 @@ export async function ensureStripeCustomer( .update(tables.organization) .set({ stripeCustomerId, - updatedAt: new Date(), }) .where(eq(tables.organization.id, organizationId)); } @@ -42,6 +42,103 @@ export async function ensureStripeCustomer( return stripeCustomerId; } +/** + * Unified helper to resolve organizationId from various Stripe event sources + * and validate that the organization exists in the database. + */ +async function resolveOrganizationFromStripeEvent(eventData: { + metadata?: { organizationId?: string }; + customer?: string; + subscription?: string; + lines?: { data?: Array<{ metadata?: { organizationId?: string } }> }; +}): Promise<{ organizationId: string; organization: any } | null> { + let organizationId: string | null = null; + + // 1. Try to get organizationId from direct metadata + if (eventData.metadata?.organizationId) { + organizationId = eventData.metadata.organizationId; + console.log(`Found organizationId in direct metadata: ${organizationId}`); + } + + // 2. Check line items metadata (common in invoices) + if (!organizationId && eventData.lines?.data) { + console.log( + `Checking ${eventData.lines.data.length} line items for organizationId`, + ); + for (const lineItem of eventData.lines.data) { + if (lineItem.metadata?.organizationId) { + organizationId = lineItem.metadata.organizationId; + console.log( + `Found organizationId in line item metadata: ${organizationId}`, + ); + break; + } + } + } + + // 3. Try to get from subscription metadata if subscription ID is available + if (!organizationId && eventData.subscription) { + try { + const stripeSubscription = await stripe.subscriptions.retrieve( + eventData.subscription, + ); + if (stripeSubscription.metadata?.organizationId) { + organizationId = stripeSubscription.metadata.organizationId; + console.log( + `Found organizationId in subscription metadata: ${organizationId}`, + ); + } + } catch (error) { + console.error("Error retrieving subscription:", error); + } + } + + // 4. Fallback: find organization by Stripe customer ID + if (!organizationId && eventData.customer) { + const organization = await db.query.organization.findFirst({ + where: { + stripeCustomerId: eventData.customer, + }, + }); + + if (organization) { + organizationId = organization.id; + console.log( + `Found organizationId via customer lookup: ${organizationId}`, + ); + } + } + + if (!organizationId) { + console.error(`Organization not found for event data:`, { + hasMetadata: !!eventData.metadata, + customer: eventData.customer, + subscription: eventData.subscription, + lineItemsCount: eventData.lines?.data?.length || 0, + }); + return null; + } + + // Validate that the organization exists + const organization = await db.query.organization.findFirst({ + where: { + id: organizationId, + }, + }); + + if (!organization) { + console.error( + `Organization with ID ${organizationId} does not exist in database`, + ); + return null; + } + + console.log( + `Successfully resolved organization: ${organization.name} (${organization.id})`, + ); + return { organizationId, organization }; +} + export const stripeRoutes = new OpenAPIHono(); const webhookHandler = createRoute({ @@ -81,12 +178,21 @@ stripeRoutes.openapi(webhookHandler, async (c) => { switch (event.type) { case "payment_intent.succeeded": - await handlePaymentIntentSucceeded(event.data.object); + await handlePaymentIntentSucceeded(event); break; case "payment_intent.payment_failed": break; case "setup_intent.succeeded": - await handleSetupIntentSucceeded(event.data.object); + await handleSetupIntentSucceeded(event); + break; + case "invoice.payment_succeeded": + await handleInvoicePaymentSucceeded(event); + break; + case "customer.subscription.updated": + await handleSubscriptionUpdated(event); + break; + case "customer.subscription.deleted": + await handleSubscriptionDeleted(event); break; default: console.log(`Unhandled event type: ${event.type}`); @@ -101,26 +207,23 @@ 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 { organizationId } = metadata; - - if (!organizationId) { - console.error("Missing organizationId in paymentIntent metadata"); - return; - } - const organization = await db.query.organization.findFirst({ - where: { - id: organizationId, - }, + const result = await resolveOrganizationFromStripeEvent({ + metadata, }); - if (!organization) { - console.error(`Organization not found: ${organizationId}`); + if (!result) { + console.error("Could not resolve organization from payment intent"); return; } + const { organizationId, organization } = result; + // Convert amount from cents to dollars const amountInDollars = amount / 100; @@ -129,7 +232,6 @@ async function handlePaymentIntentSucceeded(paymentIntent: any) { .update(tables.organization) .set({ credits: sql`${tables.organization.credits} + ${amountInDollars}`, - updatedAt: new Date(), }) .where(eq(tables.organization.id, organizationId)); @@ -139,8 +241,6 @@ async function handlePaymentIntentSucceeded(paymentIntent: any) { type: "credit", amount: amountInDollars.toString(), description: "Payment received via Stripe", - createdAt: new Date(), - updatedAt: new Date(), }); posthog.groupIdentify({ @@ -168,9 +268,12 @@ 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; + const organizationId = metadata?.organizationId; if (!organizationId || !payment_method) { console.error("Missing organizationId or payment_method in setupIntent"); @@ -185,11 +288,14 @@ async function handleSetupIntentSucceeded(setupIntent: any) { 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: { @@ -200,11 +306,245 @@ async function handleSetupIntentSucceeded(setupIntent: any) { const isDefault = existingPaymentMethods.length === 0; await db.insert(tables.paymentMethod).values({ - stripePaymentMethodId: payment_method, + stripePaymentMethodId: paymentMethodId, organizationId, type: paymentMethod.type, isDefault, - createdAt: new Date(), - updatedAt: new Date(), }); } + +async function handleInvoicePaymentSucceeded( + event: Stripe.InvoicePaymentSucceededEvent, +) { + const invoice = event.data.object; + const { customer, metadata } = invoice; + const subscription = (invoice as any).subscription; + + // Extract subscription ID from line items if not directly available + let subscriptionId = + typeof subscription === "string" ? subscription : subscription?.id; + if ( + !subscriptionId && + invoice.lines && + invoice.lines.data && + invoice.lines.data.length > 0 + ) { + const firstLineItem = invoice.lines.data[0]; + if ( + firstLineItem.parent && + firstLineItem.parent.subscription_item_details + ) { + subscriptionId = + firstLineItem.parent.subscription_item_details.subscription; + } + } + + console.log( + `Processing invoice payment succeeded for customer: ${customer}, subscription: ${subscriptionId}`, + ); + + if (!subscriptionId) { + console.log("Not a subscription invoice, skipping"); + return; // Not a subscription invoice + } + + const result = await resolveOrganizationFromStripeEvent({ + metadata: metadata as { organizationId?: string } | undefined, + customer: typeof customer === "string" ? customer : customer?.id, + subscription: subscriptionId, + lines: invoice.lines, + }); + + if (!result) { + console.error( + `Organization not found for customer: ${customer}, subscription: ${subscriptionId}`, + ); + return; + } + + const { organizationId, organization } = result; + + console.log( + `Found organization: ${organization.name} (${organization.id}), current plan: ${organization.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, + }) + .where(eq(tables.organization.id, organizationId)) + .returning(); + + console.log( + `Successfully upgraded organization ${organizationId} to pro plan. Updated rows:`, + result.length, + ); + + // Verify the update + const updatedOrganization = await db.query.organization.findFirst({ + where: { + id: organizationId, + }, + }); + + console.log( + `Verification - organization plan is now: ${updatedOrganization?.plan}`, + ); + + // Track subscription creation in PostHog + posthog.groupIdentify({ + groupType: "organization", + groupKey: organizationId, + properties: { + name: organization.name, + }, + }); + posthog.capture({ + distinctId: "organization", + event: "subscription_created", + groups: { + organization: organizationId, + }, + properties: { + plan: "pro", + organization: organizationId, + subscriptionId: subscriptionId, + source: "stripe_invoice", + }, + }); + } catch (error) { + console.error( + `Error updating organization ${organizationId} to pro plan:`, + error, + ); + throw error; + } +} + +async function handleSubscriptionUpdated( + event: Stripe.CustomerSubscriptionUpdatedEvent, +) { + const subscription = event.data.object; + const { customer, metadata } = subscription; + + 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) { + console.error(`Organization not found for customer: ${customer}`); + return; + } + + 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, + }) + .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}, cancelled: ${!isSubscriptionActive}`, + ); +} + +async function handleSubscriptionDeleted( + event: Stripe.CustomerSubscriptionDeletedEvent, +) { + const subscription = event.data.object; + const { customer, metadata } = subscription; + + const result = await resolveOrganizationFromStripeEvent({ + metadata: metadata as { organizationId?: string } | undefined, + customer: typeof customer === "string" ? customer : customer?.id, + }); + + if (!result) { + console.error(`Organization not found for customer: ${customer}`); + return; + } + + const { organizationId } = result; + + // Downgrade organization to free plan and mark subscription as cancelled + await db + .update(tables.organization) + .set({ + plan: "free", + stripeSubscriptionId: null, + planExpiresAt: null, + subscriptionCancelled: true, + }) + .where(eq(tables.organization.id, organizationId)); + + // Track subscription cancellation in PostHog + posthog.groupIdentify({ + groupType: "organization", + groupKey: organizationId, + properties: { + name: result.organization.name, + }, + }); + posthog.capture({ + distinctId: "organization", + event: "subscription_cancelled", + groups: { + organization: organizationId, + }, + properties: { + previousPlan: "pro", + newPlan: "free", + organization: organizationId, + source: "stripe_subscription_deleted", + }, + }); + + console.log(`Downgraded organization ${organizationId} to free plan`); +} diff --git a/apps/gateway/src/api.e2e.ts b/apps/gateway/src/api.e2e.ts index d83c4c774a..ee2b8ad63f 100644 --- a/apps/gateway/src/api.e2e.ts +++ b/apps/gateway/src/api.e2e.ts @@ -79,6 +79,7 @@ describe("e2e tests with real provider keys", () => { await db.insert(tables.organization).values({ id: "org-id", name: "Test Organization", + plan: "pro", }); await db.insert(tables.userOrganization).values({ @@ -91,6 +92,7 @@ describe("e2e tests with real provider keys", () => { id: "project-id", name: "Test Project", organizationId: "org-id", + mode: "api-keys", }); await db.insert(tables.apiKey).values({ @@ -438,8 +440,6 @@ describe("e2e tests with real provider keys", () => { baseUrl: "https://api.openai.com", // Use real OpenAI endpoint for testing status: "active", organizationId: "org-id", - createdAt: new Date(), - updatedAt: new Date(), }); await db.insert(tables.apiKey).values({ diff --git a/apps/gateway/src/api.spec.ts b/apps/gateway/src/api.spec.ts index 4913920f89..b5241cfc11 100644 --- a/apps/gateway/src/api.spec.ts +++ b/apps/gateway/src/api.spec.ts @@ -63,6 +63,7 @@ describe("test", () => { await db.insert(tables.organization).values({ id: "org-id", name: "Test Organization", + plan: "pro", }); await db.insert(tables.userOrganization).values({ @@ -75,6 +76,7 @@ describe("test", () => { id: "project-id", name: "Test Project", organizationId: "org-id", + mode: "api-keys", }); }); diff --git a/apps/gateway/src/worker.ts b/apps/gateway/src/worker.ts index 0188f97b7b..29efede3b3 100644 --- a/apps/gateway/src/worker.ts +++ b/apps/gateway/src/worker.ts @@ -19,7 +19,6 @@ export async function processLogQueue(): Promise { await db.insert(log).values( logData.map((i) => ({ - createdAt: new Date(), ...i, })), ); 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..e071d0fe94 --- /dev/null +++ b/apps/ui/src/components/billing/plan-management.tsx @@ -0,0 +1,389 @@ +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 queryClient = useQueryClient(); + + const { data: subscriptionStatus } = $api.useQuery( + "get", + "/subscriptions/status", + ); + + const cancelSubscriptionMutation = $api.useMutation( + "post", + "/subscriptions/cancel-pro-subscription", + ); + + const resumeSubscriptionMutation = $api.useMutation( + "post", + "/subscriptions/resume-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({}); + await queryClient.invalidateQueries({ + queryKey: $api.queryOptions("get", "/subscriptions/status").queryKey, + }); + 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", + }); + } + }; + + const handleResumeSubscription = async () => { + const confirmed = window.confirm( + "Are you sure you want to resume your Pro subscription? Your subscription will continue and you'll be charged at the next billing cycle.", + ); + + if (!confirmed) { + return; + } + + try { + await resumeSubscriptionMutation.mutateAsync({}); + await queryClient.invalidateQueries({ + queryKey: $api.queryOptions("get", "/subscriptions/status").queryKey, + }); + toast({ + title: "Subscription Resumed", + description: + "Your Pro subscription has been resumed. You'll continue to have access to all Pro features.", + }); + } catch (error) { + toast({ + title: "Error", + description: `Failed to resume 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?.subscriptionCancelled + ? `Expires on ${planExpiresAt.toDateString()}` + : `Renews on ${planExpiresAt.toDateString()}`} +

+ )} +
+
+

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

+
+
+ +
+

Plan Features

+
+
+
+
+ Provider API Keys + {!isProPlan && ( + + Pro Only + + )} +
+
+
+ Credits System +
+
+
+
+
+ API Access +
+
+
+ Usage Analytics +
+
+
+
+ + + {!isProPlan ? ( + + + + + + setUpgradeDialogOpen(false)} /> + + + ) : ( +
+ {!subscriptionStatus?.subscriptionCancelled && ( + + )} + {subscriptionStatus?.subscriptionCancelled && ( +
+ 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! You may need to refresh the page.", + }); + 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", + }); + return; + } + const status = result.paymentIntent?.status; + + 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 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) { + 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({