feat: stripe integration

This commit is contained in:
Vasily Zubarev
2025-04-24 15:27:44 +02:00
parent 38a5c0f814
commit abd5ad8403
31 changed files with 559 additions and 112 deletions

View File

@@ -1,6 +1,7 @@
"use server"
import { ActionState } from "@/lib/actions"
import config from "@/lib/config"
import OpenAI from "openai"
import { AnalyzeAttachment } from "./attachments"
@@ -24,7 +25,7 @@ export async function analyzeTransaction(
try {
const response = await openai.responses.create({
model: "gpt-4o-mini",
model: config.ai.modelName,
input: [
{
role: "user",

View File

@@ -1,7 +1,7 @@
"use server"
import { ActionState } from "@/lib/actions"
import { getCurrentUser } from "@/lib/auth"
import { getCurrentUser, isSubscriptionExpired } from "@/lib/auth"
import { getDirectorySize, getUserUploadsDirectory, isEnoughStorageToUploadFile, unsortedFilePath } from "@/lib/files"
import { createFile } from "@/models/files"
import { updateUser } from "@/models/users"
@@ -23,6 +23,13 @@ export async function uploadFilesAction(formData: FormData): Promise<ActionState
return { success: false, error: `Insufficient storage to upload these files` }
}
if (isSubscriptionExpired(user)) {
return {
success: false,
error: "Your subscription has expired, please upgrade your account or buy new subscription plan",
}
}
// Process each file
const uploadedFiles = await Promise.all(
files.map(async (file) => {

View File

@@ -1,9 +1,10 @@
import { SubscriptionExpired } from "@/components/auth/subscription-expired"
import ScreenDropArea from "@/components/files/screen-drop-area"
import MobileMenu from "@/components/sidebar/mobile-menu"
import { AppSidebar } from "@/components/sidebar/sidebar"
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
import { Toaster } from "@/components/ui/sonner"
import { getCurrentUser } from "@/lib/auth"
import { getCurrentUser, isSubscriptionExpired } from "@/lib/auth"
import config from "@/lib/config"
import { getUnsortedFilesCount } from "@/models/files"
import type { Metadata, Viewport } from "next"
@@ -39,7 +40,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
avatar: user.avatar || undefined,
storageUsed: user.storageUsed || 0,
storageLimit: user.storageLimit || -1,
tokenBalance: user.tokenBalance || 0,
aiBalance: user.aiBalance || 0,
}
return (
@@ -52,7 +53,10 @@ export default async function RootLayout({ children }: { children: React.ReactNo
unsortedFilesCount={unsortedFilesCount}
isSelfHosted={config.selfHosted.isEnabled}
/>
<SidebarInset className="w-full h-full mt-[60px] md:mt-0 overflow-auto">{children}</SidebarInset>
<SidebarInset className="w-full h-full mt-[60px] md:mt-0 overflow-auto">
{isSubscriptionExpired(user) && <SubscriptionExpired />}
{children}
</SidebarInset>
</SidebarProvider>
<Toaster />
</ScreenDropArea>

View File

@@ -13,7 +13,7 @@ const settingsCategories = [
href: "/settings",
},
{
title: "My Profile",
title: "Profile & Plan",
href: "/settings/profile",
},
{

View File

@@ -2,7 +2,7 @@
import { transactionFormSchema } from "@/forms/transactions"
import { ActionState } from "@/lib/actions"
import { getCurrentUser } from "@/lib/auth"
import { getCurrentUser, isSubscriptionExpired } from "@/lib/auth"
import {
getDirectorySize,
getTransactionFileUploadPath,
@@ -138,11 +138,19 @@ export async function uploadTransactionFilesAction(formData: FormData): Promise<
const userUploadsDirectory = await getUserUploadsDirectory(user)
// Check limits
const totalFileSize = files.reduce((acc, file) => acc + file.size, 0)
if (!isEnoughStorageToUploadFile(user, totalFileSize)) {
return { success: false, error: `Insufficient storage to upload new files` }
}
if (isSubscriptionExpired(user)) {
return {
success: false,
error: "Your subscription has expired, please upgrade your account or buy new subscription plan",
}
}
const fileRecords = await Promise.all(
files.map(async (file) => {
const fileUuid = randomUUID()

View File

@@ -6,7 +6,7 @@ import { buildLLMPrompt } from "@/ai/prompt"
import { fieldsToJsonSchema } from "@/ai/schema"
import { transactionFormSchema } from "@/forms/transactions"
import { ActionState } from "@/lib/actions"
import { getCurrentUser } from "@/lib/auth"
import { getCurrentUser, isSubscriptionExpired } from "@/lib/auth"
import config from "@/lib/config"
import { getTransactionFileUploadPath, getUserUploadsDirectory } from "@/lib/files"
import { DEFAULT_PROMPT_ANALYSE_NEW_FILE } from "@/models/defaults"
@@ -36,8 +36,20 @@ export async function analyzeFileAction(
return { success: false, error: "OpenAI API key is not set" }
}
if (!config.selfHosted.isEnabled && user.tokenBalance < 0) {
return { success: false, error: "You used all your AI tokens, please upgrade your account" }
if (!config.selfHosted.isEnabled) {
if (user.aiBalance <= 0) {
return {
success: false,
error: "You used all of your pre-paid AI scans, please upgrade your account or buy new subscription plan",
}
}
if (isSubscriptionExpired(user)) {
return {
success: false,
error: "Your subscription has expired, please upgrade your account or buy new subscription plan",
}
}
}
let attachments: AnalyzeAttachment[] = []
@@ -62,7 +74,7 @@ export async function analyzeFileAction(
console.log("Analysis results:", results)
if (results.data?.tokensUsed && results.data.tokensUsed > 0) {
await updateUser(user.id, { tokenBalance: { decrement: results.data.tokensUsed } })
await updateUser(user.id, { aiBalance: { decrement: 1 } })
}
return results

View File

@@ -2,12 +2,12 @@
import { createUserDefaults, isDatabaseEmpty } from "@/models/defaults"
import { updateSettings } from "@/models/settings"
import { createSelfHostedUser } from "@/models/users"
import { getOrCreateSelfHostedUser } from "@/models/users"
import { revalidatePath } from "next/cache"
import { redirect } from "next/navigation"
export async function selfHostedGetStartedAction(formData: FormData) {
const user = await createSelfHostedUser()
const user = await getOrCreateSelfHostedUser()
if (await isDatabaseEmpty(user.id)) {
await createUserDefaults(user.id)

43
app/(auth)/cloud/page.tsx Normal file
View File

@@ -0,0 +1,43 @@
import { PricingCard } from "@/components/auth/pricing-card"
import { Card, CardContent, CardTitle } from "@/components/ui/card"
import { ColoredText } from "@/components/ui/colored-text"
import config from "@/lib/config"
import { PLANS } from "@/lib/stripe"
import Link from "next/link"
import { redirect } from "next/navigation"
export default async function ChoosePlanPage() {
if (config.selfHosted.isEnabled) {
redirect(config.selfHosted.redirectUrl)
}
return (
<div className="container mx-auto px-4 py-8">
<Card className="w-full max-w-4xl mx-auto p-8 flex flex-col items-center justify-center gap-8">
<CardTitle className="text-4xl font-bold text-center">
<ColoredText>Choose your Cloud Edition plan</ColoredText>
</CardTitle>
<CardContent className="w-full">
{config.auth.disableSignup ? (
<div className="text-center text-md text-muted-foreground">
Creating new account is disabled for now. Please use the self-hosted version.
</div>
) : (
<div className="flex flex-wrap justify-center gap-8">
{Object.values(PLANS)
.filter((plan) => plan.isAvailable)
.map((plan) => (
<PricingCard key={plan.code} plan={plan} />
))}
</div>
)}
</CardContent>
<div className="text-center text-muted-foreground">
<Link href="mailto:me@vas3k.com" className="hover:text-primary transition-colors">
Contact us for custom plans
</Link>
</div>
</Card>
</div>
)
}

View File

@@ -0,0 +1,74 @@
import { LoginForm } from "@/components/auth/login-form"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardFooter, CardTitle } from "@/components/ui/card"
import { ColoredText } from "@/components/ui/colored-text"
import config from "@/lib/config"
import { PLANS, stripeClient } from "@/lib/stripe"
import { createUserDefaults, isDatabaseEmpty } from "@/models/defaults"
import { getOrCreateCloudUser } from "@/models/users"
import { Cake, Ghost } from "lucide-react"
import Link from "next/link"
import { redirect } from "next/navigation"
import Stripe from "stripe"
export default async function CloudPaymentSuccessPage({
searchParams,
}: {
searchParams: Promise<{ session_id: string }>
}) {
const { session_id: sessionId } = await searchParams
if (!stripeClient || !sessionId) {
redirect(config.auth.loginUrl)
}
const session = await stripeClient.checkout.sessions.retrieve(sessionId)
if (session.mode === "subscription" && session.status === "complete") {
const subscription = (await stripeClient.subscriptions.retrieve(
session.subscription as string
)) as Stripe.Subscription
const plan = Object.values(PLANS).find((p) => p.stripePriceId === subscription.items.data[0].price.id)
const email = session.customer_details?.email || session.customer_email || ""
const user = await getOrCreateCloudUser(email, {
email: email,
name: session.customer_details?.name || session.customer_details?.email || session.customer_email || "",
stripeCustomerId: session.customer as string,
membershipPlan: plan?.code,
membershipExpiresAt: new Date(subscription.items.data[0].current_period_end * 1000),
storageLimit: plan?.limits.storage,
aiBalance: plan?.limits.ai,
})
if (await isDatabaseEmpty(user.id)) {
await createUserDefaults(user.id)
}
return (
<Card className="w-full max-w-xl mx-auto p-8 flex flex-col items-center justify-center gap-4">
<Cake className="w-36 h-36" />
<CardTitle className="text-3xl font-bold ">
<ColoredText>Payment Successful</ColoredText>
</CardTitle>
<CardDescription className="text-center text-xl">You can login to your account now</CardDescription>
<CardContent className="w-full">
<LoginForm defaultEmail={user.email} />
</CardContent>
</Card>
)
} else {
return (
<Card className="w-full max-w-xl mx-auto p-8 flex flex-col items-center justify-center gap-4">
<Ghost className="w-36 h-36" />
<CardTitle className="text-3xl font-bold ">Payment Failed</CardTitle>
<CardDescription className="text-center text-xl">Please try again...</CardDescription>
<CardFooter>
<Button asChild>
<Link href="/">Go Home</Link>
</Button>
</CardFooter>
</Card>
)
}
}

View File

@@ -1,30 +0,0 @@
import SignupForm from "@/components/auth/signup-form"
import { Card, CardContent, CardTitle } from "@/components/ui/card"
import { ColoredText } from "@/components/ui/colored-text"
import config from "@/lib/config"
import Image from "next/image"
import { redirect } from "next/navigation"
export default async function LoginPage() {
if (config.selfHosted.isEnabled) {
redirect(config.selfHosted.redirectUrl)
}
return (
<Card className="w-full max-w-xl mx-auto p-8 flex flex-col items-center justify-center gap-4">
<Image src="/logo/512.png" alt="Logo" width={144} height={144} className="w-36 h-36" />
<CardTitle className="text-3xl font-bold ">
<ColoredText>TaxHacker: Cloud Edition</ColoredText>
</CardTitle>
<CardContent className="w-full">
{config.auth.disableSignup ? (
<div className="text-center text-md text-muted-foreground">
Creating new account is disabled for now. Please use the self-hosted version.
</div>
) : (
<SignupForm />
)}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,46 @@
import config from "@/lib/config"
import { PLANS, stripeClient } from "@/lib/stripe"
import { NextRequest, NextResponse } from "next/server"
export async function POST(request: NextRequest) {
const { searchParams } = new URL(request.url)
const code = searchParams.get("code")
if (!code) {
return NextResponse.json({ error: "Missing plan code" }, { status: 400 })
}
if (!stripeClient) {
return NextResponse.json({ error: "Stripe is not enabled" }, { status: 500 })
}
const plan = PLANS[code]
if (!plan || !plan.isAvailable) {
return NextResponse.json({ error: "Invalid or inactive plan" }, { status: 400 })
}
try {
const session = await stripeClient.checkout.sessions.create({
billing_address_collection: "auto",
line_items: [
{
price: plan.stripePriceId,
quantity: 1,
},
],
mode: "subscription",
success_url: config.stripe.paymentSuccessUrl,
cancel_url: config.stripe.paymentCancelUrl,
})
if (!session.url) {
console.log(session)
return NextResponse.json({ error: `Failed to create checkout session: ${session}` }, { status: 500 })
}
return NextResponse.json({ session })
} catch (error) {
console.error(error)
return NextResponse.json({ error: `Failed to create checkout session: ${error}` }, { status: 500 })
}
}

View File

@@ -0,0 +1,30 @@
import { getCurrentUser } from "@/lib/auth"
import { stripeClient } from "@/lib/stripe"
import { NextRequest, NextResponse } from "next/server"
export async function GET(request: NextRequest) {
const user = await getCurrentUser()
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
if (!stripeClient) {
return new NextResponse("Stripe client is not initialized", { status: 500 })
}
try {
if (!user.stripeCustomerId) {
return NextResponse.json({ error: "No Stripe customer ID found for this user" }, { status: 400 })
}
const portalSession = await stripeClient.billingPortal.sessions.create({
customer: user.stripeCustomerId,
return_url: `${request.nextUrl.origin}/settings/profile`,
})
return NextResponse.redirect(portalSession.url)
} catch (error) {
console.error("Stripe portal error:", error)
return NextResponse.json({ error: "Failed to create Stripe portal session" }, { status: 500 })
}
}

View File

@@ -0,0 +1,80 @@
import config from "@/lib/config"
import { PLANS, stripeClient } from "@/lib/stripe"
import { createUserDefaults, isDatabaseEmpty } from "@/models/defaults"
import { getOrCreateCloudUser, getUserByStripeCustomerId, updateUser } from "@/models/users"
import { NextResponse } from "next/server"
import Stripe from "stripe"
export async function POST(request: Request) {
const signature = request.headers.get("stripe-signature")
const body = await request.text()
if (!signature || !config.stripe.webhookSecret) {
return new NextResponse("Webhook signature or secret missing", { status: 400 })
}
if (!stripeClient) {
return new NextResponse("Stripe client is not initialized", { status: 500 })
}
let event: Stripe.Event
try {
event = stripeClient.webhooks.constructEvent(body, signature, config.stripe.webhookSecret)
} catch (err) {
console.error(`Webhook signature verification failed:`, err)
return new NextResponse("Webhook signature verification failed", { status: 400 })
}
console.log("Webhook event:", event)
// Handle the event
try {
switch (event.type) {
case "customer.subscription.created":
case "customer.subscription.updated": {
const subscription = event.data.object as Stripe.Subscription
const customerId = subscription.customer as string
const item = subscription.items.data[0]
// Get the plan from our plans configuration
const plan = Object.values(PLANS).find((p) => p.stripePriceId === item.price.id)
if (!plan) {
throw new Error(`Plan not found for price ID: ${item.price.id}`)
}
let user = await getUserByStripeCustomerId(customerId)
if (!user) {
const customer = (await stripeClient.customers.retrieve(customerId)) as Stripe.Customer
user = await getOrCreateCloudUser(customer.email as string, {
email: customer.email as string,
name: customer.name as string,
stripeCustomerId: customer.id,
})
if (await isDatabaseEmpty(user.id)) {
await createUserDefaults(user.id)
}
}
await updateUser(user.id, {
membershipPlan: plan.code,
membershipExpiresAt: new Date(item.current_period_end * 1000),
storageLimit: plan.limits.storage,
aiBalance: plan.limits.ai,
updatedAt: new Date(),
})
break
}
default:
console.log(`Unhandled event type ${event.type}`)
}
return new NextResponse("Webhook processed successfully", { status: 200 })
} catch (error) {
console.error("Error processing webhook:", error)
return new NextResponse("Webhook processing failed", { status: 500 })
}
}

View File

@@ -1,4 +1,4 @@
export default function AI() {
export default async function AI() {
return (
<div className="prose prose-slate max-w-none">
<h1 className="text-3xl font-bold tracking-tight text-gray-900 mb-6">AI Use Disclosure</h1>
@@ -18,8 +18,9 @@ export default function AI() {
</p>
<p className="text-gray-700 leading-relaxed mb-6">
At TaxHacker, we use artificial intelligence ("AI") to power the core features of our platform. This document
outlines how and why we use AI technologies, what data is processed, and how it may affect you as a user.
At TaxHacker, we use artificial intelligence (&quot;AI&quot;) to power the core features of our platform. This
document outlines how and why we use AI technologies, what data is processed, and how it may affect you as a
user.
</p>
<h2 className="text-2xl font-semibold text-gray-800 mt-8 mb-4">1. Purpose of AI in TaxHacker</h2>
@@ -52,7 +53,7 @@ export default function AI() {
<h2 className="text-2xl font-semibold text-gray-800 mt-8 mb-4">3. Data Sent for AI Processing</h2>
<p className="text-gray-700 leading-relaxed mb-3">
To deliver AI-powered features, we send selected user data to OpenAI's API, including:
To deliver AI-powered features, we send selected user data to OpenAI&apos;s API, including:
</p>
<ul className="list-disc pl-6 space-y-2 mb-6 text-gray-700">
<li>Uploaded documents (e.g., receipts, invoices)</li>

View File

@@ -1,4 +1,4 @@
export default function Cookie() {
export default async function Cookie() {
return (
<div className="prose prose-slate max-w-none">
<h1 className="text-3xl font-bold mb-6 text-slate-900 border-b pb-2">Cookie Policy</h1>

View File

@@ -1,4 +1,4 @@
export default function PrivacyPolicy() {
export default async function PrivacyPolicy() {
return (
<div className="prose prose-slate max-w-none">
<h2 className="text-3xl font-bold mb-6 text-slate-900 border-b pb-2">
@@ -25,8 +25,8 @@ export default function PrivacyPolicy() {
</p>
<p className="text-slate-700 mb-6 leading-relaxed">
TaxHacker ("we", "our", "us") is committed to protecting your privacy. This Privacy Policy describes how we
collect, use, store, and protect your personal data when you use our services at{" "}
TaxHacker (&quot;we&quot;, &quot;our&quot;, &quot;us&quot;) is committed to protecting your privacy. This
Privacy Policy describes how we collect, use, store, and protect your personal data when you use our services at{" "}
<a href="https://taxhacker.app" className="text-blue-600 hover:text-blue-800">
taxhacker.app
</a>
@@ -202,7 +202,7 @@ export default function PrivacyPolicy() {
</h3>
<p className="text-slate-700 mb-6 leading-relaxed">
We may update this Privacy Policy from time to time. Any changes will be published on this page with an updated
"Effective Date." We encourage you to review the policy periodically.
&quot;Effective Date.&quot; We encourage you to review the policy periodically.
</p>
</div>
)

View File

@@ -1,4 +1,4 @@
export default function Terms() {
export default async function Terms() {
return (
<div className="prose prose-slate max-w-none">
<h1 className="text-3xl font-bold mb-6 text-slate-900 border-b pb-2">Terms of Service</h1>
@@ -17,9 +17,9 @@ export default function Terms() {
</p>
<p className="text-slate-700 mb-6 leading-relaxed">
These Terms of Service ("Terms") govern your access to and use of TaxHacker, an automated invoice analyzer and
expense tracker powered by artificial intelligence (AI). By accessing or using our services, you agree to be
bound by these Terms.
These Terms of Service (&quot;Terms&quot;) govern your access to and use of TaxHacker, an automated invoice
analyzer and expense tracker powered by artificial intelligence (AI). By accessing or using our services, you
agree to be bound by these Terms.
</p>
<h2 className="text-2xl font-semibold text-slate-800 mb-4">1. Service Overview</h2>
@@ -118,7 +118,8 @@ export default function Terms() {
<h2 className="text-2xl font-semibold text-slate-800 mb-4">7. Limitations of Liability</h2>
<ul className="list-disc pl-6 mb-6 space-y-2 text-slate-700">
<li>
TaxHacker is provided <strong className="text-slate-800">"as is"</strong>, without warranties of any kind.
TaxHacker is provided <strong className="text-slate-800">&quot;as is&quot;</strong>, without warranties of any
kind.
</li>
<li>
We make <strong className="text-slate-800">no guarantees</strong> about the accuracy of AI-generated outputs

View File

@@ -7,8 +7,8 @@ import { authClient } from "@/lib/auth-client"
import { useRouter } from "next/navigation"
import { useState } from "react"
export function LoginForm() {
const [email, setEmail] = useState("")
export function LoginForm({ defaultEmail }: { defaultEmail?: string }) {
const [email, setEmail] = useState(defaultEmail || "")
const [otp, setOtp] = useState("")
const [isOtpSent, setIsOtpSent] = useState(false)
const [isLoading, setIsLoading] = useState(false)

View File

@@ -0,0 +1,62 @@
"use client"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import { Plan } from "@/lib/stripe"
import { Check, Loader2 } from "lucide-react"
import { useState } from "react"
import { FormError } from "../forms/error"
export function PricingCard({ plan, hideButton = false }: { plan: Plan; hideButton?: boolean }) {
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleClick = async () => {
setIsLoading(true)
setError(null)
try {
const response = await fetch(`/api/stripe/checkout?code=${plan.code}`, {
method: "POST",
})
const data = await response.json()
if (data.error) {
setError(data.error)
} else {
window.location.href = data.session.url
}
} catch (error) {
setError(error instanceof Error ? error.message : "An unknown error occurred")
} finally {
setIsLoading(false)
}
}
return (
<Card className="w-full max-w-xs relative overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-br from-primary/10 to-secondary/10" />
<CardHeader className="relative">
<CardTitle className="text-3xl">{plan.name}</CardTitle>
<CardDescription>{plan.description}</CardDescription>
{plan.price && <div className="text-2xl font-bold mt-4">{plan.price}</div>}
</CardHeader>
<CardContent className="relative">
<ul className="space-y-2">
{plan.benefits.map((benefit, index) => (
<li key={index} className="flex items-center gap-2">
<Check className="h-4 w-4 text-primary" />
<span>{benefit}</span>
</li>
))}
</ul>
</CardContent>
<CardFooter className="flex flex-col gap-2 relative">
{!hideButton && (
<Button className="w-full" onClick={handleClick} disabled={isLoading}>
{isLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : "Get Started"}
</Button>
)}
{error && <FormError>{error}</FormError>}
</CardFooter>
</Card>
)
}

View File

@@ -0,0 +1,12 @@
import Link from "next/link"
export function SubscriptionExpired() {
return (
<Link
href="/settings/profile"
className="w-full h-8 p-1 bg-red-500 text-white font-semibold text-center hover:bg-red-600 transition-colors"
>
Your subscription has expired. Click here to select a new plan. Otherwise, your account will be deleted.
</Link>
)
}

View File

@@ -4,11 +4,10 @@ import { saveProfileAction } from "@/app/(app)/settings/actions"
import { FormError } from "@/components/forms/error"
import { FormInput } from "@/components/forms/simple"
import { Button } from "@/components/ui/button"
import { Card } from "@/components/ui/card"
import { formatBytes, formatNumber } from "@/lib/utils"
import { User } from "@prisma/client"
import { CircleCheckBig } from "lucide-react"
import { useActionState } from "react"
import { SubscriptionPlan } from "./subscription-plan"
export default function ProfileSettingsForm({ user }: { user: User }) {
const [saveState, saveAction, pending] = useActionState(saveProfileAction, null)
@@ -32,13 +31,10 @@ export default function ProfileSettingsForm({ user }: { user: User }) {
{saveState?.error && <FormError>{saveState.error}</FormError>}
</form>
<Card className="mt-4 p-4">
<p>
Storage Used: {formatBytes(user.storageUsed)} /{" "}
{user.storageLimit > 0 ? formatBytes(user.storageLimit) : "Unlimited"}
</p>
<p>Tokens Balance: {formatNumber(user.tokenBalance)}</p>
</Card>
<div className="mt-10">
<SubscriptionPlan user={user} />
</div>
</div>
)
}

View File

@@ -0,0 +1,58 @@
import { User } from "@prisma/client"
import { PricingCard } from "@/components/auth/pricing-card"
import { Button } from "@/components/ui/button"
import { Card } from "@/components/ui/card"
import { PLANS } from "@/lib/stripe"
import { formatBytes, formatNumber } from "@/lib/utils"
import { formatDate } from "date-fns"
import { BrainCog, CalendarSync, HardDrive } from "lucide-react"
import Link from "next/link"
import { Badge } from "../ui/badge"
export function SubscriptionPlan({ user }: { user: User }) {
const plan = PLANS[user.membershipPlan as keyof typeof PLANS] || PLANS.unlimited
return (
<div className="flex flex-wrap gap-5">
<div className="flex flex-col gap-2 flex-1 items-center justify-center max-w-[300px]">
<PricingCard plan={plan} hideButton={true} />
<Badge variant="outline">Current Plan</Badge>
</div>
<div className="flex-1">
<Card className="w-full p-4">
<div className="space-y-2">
<strong className="text-lg">Usage:</strong>
<div className="flex items-center gap-2">
<HardDrive className="h-4 w-4" />
<span>
<strong className="font-semibold">Storage:</strong> {formatBytes(user.storageUsed)} /{" "}
{user.storageLimit > 0 ? formatBytes(user.storageLimit) : "Unlimited"}
</span>
</div>
<div className="flex items-center gap-2">
<BrainCog className="h-4 w-4" />
<span>
<strong className="font-semibold">AI Scans:</strong> {formatNumber(plan.limits.ai - user.aiBalance)} /{" "}
{plan.limits.ai > 0 ? formatNumber(plan.limits.ai) : "Unlimited"}
</span>
</div>
<div className="flex items-center gap-2">
<CalendarSync className="h-4 w-4" />
<span>
<strong className="font-semibold">Expiration Date:</strong>{" "}
{user.membershipExpiresAt ? formatDate(user.membershipExpiresAt, "yyyy-MM-dd") : "Never"}
</span>
</div>
</div>
{user.stripeCustomerId && (
<div className="space-y-4 mt-6">
<Button asChild className="w-full">
<Link href="/api/stripe/portal">Manage Subscription</Link>
</Button>
</div>
)}
</Card>
</div>
</div>
)
}

View File

@@ -1,12 +1,6 @@
import { stripeClient } from "@better-auth/stripe/client"
import { createAuthClient } from "better-auth/client"
import { emailOTPClient } from "better-auth/client/plugins"
export const authClient = createAuthClient({
plugins: [
emailOTPClient(),
stripeClient({
subscription: true,
}),
],
plugins: [emailOTPClient()],
})

View File

@@ -1,7 +1,5 @@
import config from "@/lib/config"
import { createUserDefaults } from "@/models/defaults"
import { getSelfHostedUser, getUserByEmail, getUserById } from "@/models/users"
import { stripe } from "@better-auth/stripe"
import { User } from "@prisma/client"
import { betterAuth } from "better-auth"
import { prismaAdapter } from "better-auth/adapters/prisma"
@@ -12,7 +10,6 @@ import { headers } from "next/headers"
import { redirect } from "next/navigation"
import { prisma } from "./db"
import { resend, sendOTPCodeEmail } from "./email"
import { isStripeEnabled, stripeClient } from "./stripe"
export type UserProfile = {
id: string
@@ -21,7 +18,7 @@ export type UserProfile = {
avatar?: string
storageUsed: number
storageLimit: number
tokenBalance: number
aiBalance: number
}
export const auth = betterAuth({
@@ -47,15 +44,6 @@ export const auth = betterAuth({
generateId: false,
cookiePrefix: "taxhacker",
},
databaseHooks: {
user: {
create: {
after: async (user) => {
await createUserDefaults(user.id)
},
},
},
},
plugins: [
emailOTP({
disableSignUp: config.auth.disableSignup,
@@ -69,13 +57,6 @@ export const auth = betterAuth({
await sendOTPCodeEmail({ email, otp })
},
}),
isStripeEnabled(stripeClient)
? stripe({
stripeClient: stripeClient!,
stripeWebhookSecret: config.stripe.webhookSecret,
createCustomerOnSignUp: true,
})
: { id: "stripe", endpoints: {} },
nextCookies(), // make sure this is the last plugin in the array
],
})
@@ -113,3 +94,10 @@ export async function getCurrentUser(): Promise<User> {
// No session or user found
redirect(config.auth.loginUrl)
}
export function isSubscriptionExpired(user: User) {
if (config.selfHosted.isEnabled) {
return false
}
return user.membershipExpiresAt && user.membershipExpiresAt < new Date()
}

View File

@@ -36,6 +36,7 @@ const config = {
},
ai: {
openaiApiKey: env.OPENAI_API_KEY,
modelName: "gpt-4o-mini",
},
auth: {
secret: env.BETTER_AUTH_SECRET,
@@ -45,6 +46,8 @@ const config = {
stripe: {
secretKey: env.STRIPE_SECRET_KEY,
webhookSecret: env.STRIPE_WEBHOOK_SECRET,
paymentSuccessUrl: `${env.BASE_URL}/cloud/payment/success?session_id={CHECKOUT_SESSION_ID}`,
paymentCancelUrl: `${env.BASE_URL}/cloud`,
},
email: {
apiKey: env.RESEND_API_KEY,

View File

@@ -7,7 +7,50 @@ export const stripeClient: Stripe | null = config.stripe.secretKey
})
: null
// Type guard to check if Stripe is initialized
export const isStripeEnabled = (client: Stripe | null): client is Stripe => {
return client !== null
export type Plan = {
code: string
name: string
description: string
benefits: string[]
price: string
stripePriceId: string
limits: {
storage: number
ai: number
}
isAvailable: boolean
}
export const PLANS: Record<string, Plan> = {
unlimited: {
code: "unlimited",
name: "Unlimited",
description: "Special unlimited plan",
benefits: ["Unlimited storage", "Unlimited AI analysis", "Unlimited everything"],
price: "",
stripePriceId: "",
limits: {
storage: -1,
ai: -1,
},
isAvailable: false,
},
early: {
code: "early",
name: "Early Adopter",
description: "Special plan for our early users",
benefits: [
"512 Mb of storage",
"1000 AI file analysis",
"Unlimited transactions",
"Unlimited fields, categories and projects",
],
price: "€50/year",
stripePriceId: "price_1RGzKUPKOUEUzVB3hVyo2n57",
limits: {
storage: 512 * 1024 * 1024,
ai: 1000,
},
isAvailable: true,
},
}

View File

@@ -5,6 +5,7 @@ import { cache } from "react"
export const SELF_HOSTED_USER = {
email: "taxhacker@localhost",
name: "Self-Hosted Mode",
membershipPlan: "unlimited",
}
export const getSelfHostedUser = cache(async () => {
@@ -13,7 +14,7 @@ export const getSelfHostedUser = cache(async () => {
})
})
export const createSelfHostedUser = cache(async () => {
export const getOrCreateSelfHostedUser = cache(async () => {
return await prisma.user.upsert({
where: { email: SELF_HOSTED_USER.email },
update: SELF_HOSTED_USER,
@@ -21,6 +22,14 @@ export const createSelfHostedUser = cache(async () => {
})
})
export function getOrCreateCloudUser(email: string, data: Prisma.UserCreateInput) {
return prisma.user.upsert({
where: { email },
update: data,
create: data,
})
}
export const getUserById = cache(async (id: string) => {
return await prisma.user.findUnique({
where: { id },
@@ -33,6 +42,12 @@ export const getUserByEmail = cache(async (email: string) => {
})
})
export const getUserByStripeCustomerId = cache(async (customerId: string) => {
return await prisma.user.findFirst({
where: { stripeCustomerId: customerId },
})
})
export function updateUser(userId: string, data: Prisma.UserUpdateInput) {
return prisma.user.update({
where: { id: userId },

11
package-lock.json generated
View File

@@ -8,7 +8,6 @@
"name": "taxhacker",
"version": "0.5.0",
"dependencies": {
"@better-auth/stripe": "^1.2.5",
"@fast-csv/format": "^5.0.2",
"@fast-csv/parse": "^5.0.2",
"@prisma/client": "^6.6.0",
@@ -349,16 +348,6 @@
"node": ">=6.9.0"
}
},
"node_modules/@better-auth/stripe": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@better-auth/stripe/-/stripe-1.2.5.tgz",
"integrity": "sha512-+87qnc4rtDJxzdCswJQOHTopRRcVw+93cSNz8O1TP3GcBEooEjAspHHAxSmutPm7pluLrHIX5g0uFE2MIOUbmQ==",
"license": "MIT",
"dependencies": {
"better-auth": "^1.2.5",
"zod": "^3.24.1"
}
},
"node_modules/@better-auth/utils": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/@better-auth/utils/-/utils-0.2.4.tgz",

View File

@@ -10,7 +10,6 @@
"lint": "next lint"
},
"dependencies": {
"@better-auth/stripe": "^1.2.5",
"@fast-csv/format": "^5.0.2",
"@fast-csv/parse": "^5.0.2",
"@prisma/client": "^6.6.0",

View File

@@ -0,0 +1,10 @@
/*
Warnings:
- You are about to drop the column `token_balance` on the `users` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "users" DROP COLUMN "token_balance",
ADD COLUMN "ai_balance" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "stripe_customer_id" TEXT;

View File

@@ -24,12 +24,13 @@ model User {
transactions Transaction[]
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
stripeCustomerId String? @map("stripe_customer_id")
membershipPlan String? @map("membership_plan")
membershipExpiresAt DateTime? @map("membership_expires_at")
emailVerified Boolean @default(false) @map("is_email_verified")
storageUsed Int @default(0) @map("storage_used")
storageLimit Int @default(-1) @map("storage_limit")
tokenBalance Int @default(0) @map("token_balance")
aiBalance Int @default(0) @map("ai_balance")
accounts Account[]
sessions Session[]