mirror of
https://github.com/codeflash-ai/codeflash-internal.git
synced 2026-05-04 18:25:18 +00:00
dedupe and pricing changes for period
This commit is contained in:
parent
a97ec54b2a
commit
109e1d544c
16 changed files with 266 additions and 13 deletions
|
|
@ -1,5 +1,5 @@
|
|||
import { Request, Response } from "express"
|
||||
import { stripe } from "../config/stripe-client.js"
|
||||
import { stripe } from "@codeflash-ai/common"
|
||||
import { prisma } from "@codeflash-ai/common"
|
||||
import * as Sentry from "@sentry/node"
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { Request, Response, NextFunction } from "express"
|
|||
import { prisma } from "@codeflash-ai/common"
|
||||
import { createCheckoutSession } from "../utils/subscription-utils.js"
|
||||
import * as Sentry from "@sentry/node"
|
||||
import { stripe } from "../config/stripe-client.js"
|
||||
import { stripe } from "@codeflash-ai/common"
|
||||
|
||||
// Get a user's subscription details
|
||||
export async function getSubscription(req: Request, res: Response, next: NextFunction) {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import {
|
|||
buildResultTestReport,
|
||||
buildDependentPrTitle,
|
||||
buildPrTitle,
|
||||
} from "./pr-changes-utils"
|
||||
} from "./pr-changes-utils.js"
|
||||
|
||||
const PR_COMMENT_FIELDS = {
|
||||
best_runtime: "13.0 microseconds",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { prisma } from "@codeflash-ai/common"
|
||||
import { stripe } from "../config/stripe-client.js"
|
||||
import { stripe } from "@codeflash-ai/common"
|
||||
import { STRIPE_CONFIG } from "../config/stripe.js"
|
||||
import * as Sentry from "@sentry/node"
|
||||
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@
|
|||
"@radix-ui/react-select": "^2.0.0",
|
||||
"@radix-ui/react-separator": "^1.0.3",
|
||||
"@radix-ui/react-slot": "^1.0.2",
|
||||
"@radix-ui/react-tabs": "^1.1.3",
|
||||
"@radix-ui/react-toast": "^1.1.5",
|
||||
"@radix-ui/react-tooltip": "^1.1.4",
|
||||
"@sentry/nextjs": "^8.9.2",
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
import { stripe } from "@/lib/stripe"
|
||||
import { PrismaClient } from "@prisma/client"
|
||||
|
||||
export async function upgradeSubscription(userId: string) {
|
||||
export async function upgradeSubscription(userId: string, period = "monthly") {
|
||||
try {
|
||||
// Get user info
|
||||
const prisma = new PrismaClient()
|
||||
|
|
@ -14,7 +14,11 @@ export async function upgradeSubscription(userId: string) {
|
|||
if (!user) {
|
||||
throw new Error(`User not found: ${userId}`)
|
||||
}
|
||||
|
||||
// Select price ID based on period
|
||||
const priceId =
|
||||
period === "yearly"
|
||||
? process.env.STRIPE_PRO_PRICE_YEARLY_ID
|
||||
: process.env.STRIPE_PRO_PRICE_MONTHLY_ID
|
||||
// Check for existing customer ID
|
||||
let customerId = user.subscriptions?.stripe_customer_id
|
||||
|
||||
|
|
|
|||
|
|
@ -4,14 +4,18 @@ import { Button } from "@/components/ui/button"
|
|||
import { Card, CardHeader, CardTitle, CardContent, CardFooter } from "@/components/ui/card"
|
||||
import { Progress } from "@/components/ui/progress"
|
||||
import { upgradeSubscription, cancelSubscription } from "./actions"
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { SUBSCRIPTION_PLANS } from "@codeflash-ai/common"
|
||||
|
||||
export function BillingView({ userId, subscription }) {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [billingPeriod, setBillingPeriod] = useState("monthly")
|
||||
|
||||
const handleUpgrade = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const url = await upgradeSubscription(userId)
|
||||
// Pass the selected period
|
||||
const url = await upgradeSubscription(userId, billingPeriod)
|
||||
if (url) window.location.href = url
|
||||
} catch (error) {
|
||||
console.error("Error creating checkout:", error)
|
||||
|
|
@ -36,6 +40,28 @@ export function BillingView({ userId, subscription }) {
|
|||
// Calculate usage percentage
|
||||
const usagePercent = (subscription.optimizations_used / subscription.optimizations_limit) * 100
|
||||
|
||||
const currentBillingPeriod =
|
||||
subscription.subscription_status === "active"
|
||||
? subscription.stripe_subscription_id?.includes("year")
|
||||
? "yearly"
|
||||
: "monthly"
|
||||
: null
|
||||
|
||||
const proPlan = SUBSCRIPTION_PLANS.PRO
|
||||
const formatPrice = amount => {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
minimumFractionDigits: 0,
|
||||
}).format(amount / 100)
|
||||
}
|
||||
|
||||
// Calculate yearly savings
|
||||
const monthlyCost = proPlan.monthlyPrice
|
||||
const yearlyCost = proPlan.yearlyPrice
|
||||
const monthlyCostPerYear = monthlyCost * 12
|
||||
const yearlySavings = monthlyCostPerYear - yearlyCost
|
||||
|
||||
return (
|
||||
<div className="container space-y-6 p-4">
|
||||
<h1 className="text-2xl font-bold">Billing & Subscription</h1>
|
||||
|
|
@ -45,6 +71,9 @@ export function BillingView({ userId, subscription }) {
|
|||
<CardTitle>
|
||||
Current Plan:{" "}
|
||||
{subscription.plan_type.charAt(0).toUpperCase() + subscription.plan_type.slice(1)}
|
||||
{currentBillingPeriod && (
|
||||
<span className="ml-2 text-sm text-muted-foreground">({currentBillingPeriod})</span>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
|
|
@ -65,11 +94,34 @@ export function BillingView({ userId, subscription }) {
|
|||
</CardContent>
|
||||
<CardFooter>
|
||||
{subscription.plan_type === "free" && (
|
||||
<Button onClick={handleUpgrade} disabled={isLoading}>
|
||||
{isLoading ? "Loading..." : "Upgrade to Pro"}
|
||||
</Button>
|
||||
)}
|
||||
<div className="space-y-4 w-full">
|
||||
<Tabs defaultValue="monthly" onValueChange={setBillingPeriod} className="w-full">
|
||||
<TabsList className="grid grid-cols-2 w-full">
|
||||
<TabsTrigger value="monthly">Monthly</TabsTrigger>
|
||||
<TabsTrigger value="yearly">
|
||||
Yearly (Save {formatPrice(yearlySavings)})
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
<div className="flex flex-col space-y-2">
|
||||
{billingPeriod === "monthly" ? (
|
||||
<p className="text-lg font-medium">{formatPrice(proPlan.monthlyPrice)}/month</p>
|
||||
) : (
|
||||
<p className="text-lg font-medium">
|
||||
{formatPrice(proPlan.yearlyPrice)}/year
|
||||
<span className="text-sm text-green-600 ml-2">
|
||||
Save {formatPrice(yearlySavings)}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button onClick={handleUpgrade} disabled={isLoading} className="w-full">
|
||||
{isLoading ? "Loading..." : "Upgrade to Pro"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{subscription.plan_type === "pro" && subscription.subscription_status === "active" && (
|
||||
<Button variant="outline" disabled={isLoading} onClick={handleCancel}>
|
||||
Cancel Subscription
|
||||
|
|
|
|||
30
js/cf-webapp/src/app/subscribe/pro/page.tsx
Normal file
30
js/cf-webapp/src/app/subscribe/pro/page.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
// cf-webapp/src/app/subscribe/pro/page.tsx
|
||||
"use server"
|
||||
import { getSession } from "@auth0/nextjs-auth0"
|
||||
import { redirect } from "next/navigation"
|
||||
import { createCheckoutSession } from "@codeflash-ai/common"
|
||||
|
||||
interface SearchParams {
|
||||
period?: string
|
||||
}
|
||||
export default async function ProSubscribePage({ searchParams }: { searchParams: SearchParams }) {
|
||||
const period = searchParams.period || "monthly"
|
||||
const session = await getSession()
|
||||
|
||||
// If not logged in, redirect to login with return path
|
||||
if (!session) {
|
||||
redirect(`/login?returnTo=/subscribe/pro?period=${period}`)
|
||||
}
|
||||
|
||||
const userId = session.user.sub
|
||||
|
||||
// Select price ID based on period
|
||||
const priceId =
|
||||
period === "yearly"
|
||||
? process.env.STRIPE_PRO_PRICE_YEARLY_ID
|
||||
: process.env.STRIPE_PRO_PRICE_MONTHLY_ID
|
||||
|
||||
// Generate checkout URL and redirect
|
||||
const checkoutUrl = await createCheckoutSession(userId, priceId, { period })
|
||||
redirect(checkoutUrl)
|
||||
}
|
||||
56
js/cf-webapp/src/components/ui/tabs.tsx
Normal file
56
js/cf-webapp/src/components/ui/tabs.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
// components/ui/tabs.tsx
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Tabs = TabsPrimitive.Root
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsList.displayName = TabsPrimitive.List.displayName
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
|
|
@ -6,6 +6,8 @@ STRIPE_SECRET_KEY="sk_test_..."
|
|||
STRIPE_WEBHOOK_SECRET="whsec_..."
|
||||
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY="pk_test_..."
|
||||
NEXT_PUBLIC_STRIPE_PRO_PRICE_ID="price_..."
|
||||
STRIPE_PRO_PRICE_MONTHLY_ID=price_abc123...
|
||||
STRIPE_PRO_PRICE_YEARLY_ID=price_xyz456...
|
||||
|
||||
# Environment
|
||||
NODE_ENV="development"
|
||||
NODE_ENV="development"
|
||||
|
|
|
|||
1
js/common/.gitignore
vendored
1
js/common/.gitignore
vendored
|
|
@ -4,3 +4,4 @@ dist
|
|||
|
||||
/.npmrc
|
||||
.npmrc
|
||||
/.env.development
|
||||
|
|
|
|||
|
|
@ -18,7 +18,9 @@
|
|||
"@azure/identity": "^4.2.0",
|
||||
"@azure/keyvault-secrets": "^4.8.0",
|
||||
"@azure/msal-node": "^2.9.0",
|
||||
"@prisma/client": "^6.2.1"
|
||||
"@prisma/client": "^6.2.1",
|
||||
"@sentry/node": "^9.5.0",
|
||||
"stripe": "^17.7.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.10.1",
|
||||
|
|
|
|||
|
|
@ -14,3 +14,6 @@ export * from "./user-functions"
|
|||
|
||||
export { prisma } from "./prisma-client"
|
||||
// Use prismaClient instead of new PrismaClient()
|
||||
export * from "./subscription-functions"
|
||||
export * from "./subscription-config"
|
||||
export * from "./stripe-client"
|
||||
|
|
|
|||
24
js/common/src/subscription-config.ts
Normal file
24
js/common/src/subscription-config.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
// common/src/subscription-config.ts
|
||||
export const SUBSCRIPTION_PLANS = {
|
||||
FREE: {
|
||||
name: "Free",
|
||||
optimizations: 100,
|
||||
price: 0,
|
||||
},
|
||||
PRO: {
|
||||
name: "Pro",
|
||||
optimizations: 500,
|
||||
monthlyPrice: 3000, // $30.00
|
||||
yearlyPrice: 30000, // $300.00
|
||||
},
|
||||
ENTERPRISE: {
|
||||
name: "Enterprise",
|
||||
optimizations: "Custom",
|
||||
price: "Custom",
|
||||
},
|
||||
} as const
|
||||
|
||||
export const PRICE_ID_KEYS = {
|
||||
PRO_MONTHLY: "STRIPE_PRO_PRICE_MONTHLY_ID",
|
||||
PRO_YEARLY: "STRIPE_PRO_PRICE_YEARLY_ID",
|
||||
}
|
||||
78
js/common/src/subscription-functions.ts
Normal file
78
js/common/src/subscription-functions.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import { stripe } from "./stripe-client"
|
||||
import { prisma } from "./prisma-client"
|
||||
import * as Sentry from "@sentry/node"
|
||||
|
||||
/**
|
||||
* Create a checkout session for a subscription
|
||||
*/
|
||||
export async function createCheckoutSession(
|
||||
userId: string,
|
||||
priceId: string,
|
||||
options?: {
|
||||
successUrl?: string
|
||||
cancelUrl?: string
|
||||
period?: "monthly" | "yearly"
|
||||
},
|
||||
): Promise<string> {
|
||||
try {
|
||||
// Get user info
|
||||
const user = await prisma.users.findUnique({
|
||||
where: { user_id: userId },
|
||||
include: { subscriptions: true },
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
throw new Error(`User not found: ${userId}`)
|
||||
}
|
||||
|
||||
// Use existing Stripe customer or create new one
|
||||
let customerId = user.subscriptions?.stripe_customer_id
|
||||
|
||||
if (!customerId && user.email) {
|
||||
const customer = await stripe.customers.create({
|
||||
email: user.email,
|
||||
name: user.name || undefined,
|
||||
metadata: {
|
||||
userId,
|
||||
github_username: user.github_username || "",
|
||||
},
|
||||
})
|
||||
customerId = customer.id
|
||||
}
|
||||
|
||||
// Determine period from priceId if not specified
|
||||
const period =
|
||||
options?.period || (priceId === process.env.STRIPE_PRO_PRICE_YEARLY_ID ? "yearly" : "monthly")
|
||||
|
||||
// Create checkout session
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
customer: customerId,
|
||||
line_items: [{ price: priceId, quantity: 1 }],
|
||||
mode: "subscription",
|
||||
success_url: options?.successUrl || `${process.env.WEBAPP_URL}/app/billing?success=true`,
|
||||
cancel_url: options?.cancelUrl || `${process.env.WEBAPP_URL}/app/billing?canceled=true`,
|
||||
metadata: {
|
||||
userId,
|
||||
period,
|
||||
},
|
||||
subscription_data: {
|
||||
metadata: {
|
||||
userId,
|
||||
period,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return session.url || ""
|
||||
} catch (error) {
|
||||
console.error("Error creating checkout session:", error)
|
||||
try {
|
||||
Sentry.captureException(error)
|
||||
} catch (sentryError) {
|
||||
// Ignore Sentry errors to avoid breaking functionality
|
||||
console.warn("Sentry logging failed:", sentryError)
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue