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