dedupe and pricing changes for period

This commit is contained in:
Saga4 2025-03-11 04:55:40 +05:30
parent a97ec54b2a
commit 109e1d544c
16 changed files with 266 additions and 13 deletions

View file

@ -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"

View file

@ -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) {

View file

@ -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",

View file

@ -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"

View file

@ -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",

View file

@ -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

View file

@ -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

View 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)
}

View 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 }

View file

@ -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"

View file

@ -4,3 +4,4 @@ dist
/.npmrc
.npmrc
/.env.development

View file

@ -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",

View file

@ -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"

View 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",
}

View 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
}
}