mirror of
https://github.com/marcogll/TaxHacker_s23.git
synced 2026-01-13 13:25:18 +00:00
feat: stripe integration
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -13,7 +13,7 @@ const settingsCategories = [
|
||||
href: "/settings",
|
||||
},
|
||||
{
|
||||
title: "My Profile",
|
||||
title: "Profile & Plan",
|
||||
href: "/settings/profile",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
43
app/(auth)/cloud/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
74
app/(auth)/cloud/payment/success/page.tsx
Normal file
74
app/(auth)/cloud/payment/success/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
46
app/api/stripe/checkout/route.ts
Normal file
46
app/api/stripe/checkout/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
30
app/api/stripe/portal/route.ts
Normal file
30
app/api/stripe/portal/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
80
app/api/stripe/webhook/route.ts
Normal file
80
app/api/stripe/webhook/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
@@ -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 ("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.
|
||||
</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'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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ("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{" "}
|
||||
<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.
|
||||
"Effective Date." We encourage you to review the policy periodically.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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 ("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.
|
||||
</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">"as is"</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
|
||||
|
||||
@@ -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)
|
||||
|
||||
62
components/auth/pricing-card.tsx
Normal file
62
components/auth/pricing-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
12
components/auth/subscription-expired.tsx
Normal file
12
components/auth/subscription-expired.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
58
components/settings/subscription-plan.tsx
Normal file
58
components/settings/subscription-plan.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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()],
|
||||
})
|
||||
|
||||
28
lib/auth.ts
28
lib/auth.ts
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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
11
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
@@ -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[]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user