diff --git a/ai/analyze.ts b/ai/analyze.ts index b2861c7..3f4aca9 100644 --- a/ai/analyze.ts +++ b/ai/analyze.ts @@ -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", diff --git a/app/(app)/files/actions.ts b/app/(app)/files/actions.ts index 2399cb6..022598b 100644 --- a/app/(app)/files/actions.ts +++ b/app/(app)/files/actions.ts @@ -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 { diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx index f9126d7..645fe17 100644 --- a/app/(app)/layout.tsx +++ b/app/(app)/layout.tsx @@ -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} /> - {children} + + {isSubscriptionExpired(user) && } + {children} + diff --git a/app/(app)/settings/layout.tsx b/app/(app)/settings/layout.tsx index 6c41f7b..6245d62 100644 --- a/app/(app)/settings/layout.tsx +++ b/app/(app)/settings/layout.tsx @@ -13,7 +13,7 @@ const settingsCategories = [ href: "/settings", }, { - title: "My Profile", + title: "Profile & Plan", href: "/settings/profile", }, { diff --git a/app/(app)/transactions/actions.ts b/app/(app)/transactions/actions.ts index 0c5008e..da1ae9b 100644 --- a/app/(app)/transactions/actions.ts +++ b/app/(app)/transactions/actions.ts @@ -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() diff --git a/app/(app)/unsorted/actions.ts b/app/(app)/unsorted/actions.ts index 6ae9204..29e8e67 100644 --- a/app/(app)/unsorted/actions.ts +++ b/app/(app)/unsorted/actions.ts @@ -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 diff --git a/app/(auth)/actions.ts b/app/(auth)/actions.ts index 8e30ddd..6b49dfe 100644 --- a/app/(auth)/actions.ts +++ b/app/(auth)/actions.ts @@ -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) diff --git a/app/(auth)/cloud/page.tsx b/app/(auth)/cloud/page.tsx new file mode 100644 index 0000000..f4e1eda --- /dev/null +++ b/app/(auth)/cloud/page.tsx @@ -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 ( +
+ + + Choose your Cloud Edition plan + + + {config.auth.disableSignup ? ( +
+ Creating new account is disabled for now. Please use the self-hosted version. +
+ ) : ( +
+ {Object.values(PLANS) + .filter((plan) => plan.isAvailable) + .map((plan) => ( + + ))} +
+ )} +
+
+ + Contact us for custom plans + +
+
+
+ ) +} diff --git a/app/(auth)/cloud/payment/success/page.tsx b/app/(auth)/cloud/payment/success/page.tsx new file mode 100644 index 0000000..22abaec --- /dev/null +++ b/app/(auth)/cloud/payment/success/page.tsx @@ -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 ( + + + + Payment Successful + + You can login to your account now + + + + + ) + } else { + return ( + + + Payment Failed + Please try again... + + + + + ) + } +} diff --git a/app/(auth)/signup/page.tsx b/app/(auth)/signup/page.tsx deleted file mode 100644 index f11efd5..0000000 --- a/app/(auth)/signup/page.tsx +++ /dev/null @@ -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 ( - - Logo - - TaxHacker: Cloud Edition - - - {config.auth.disableSignup ? ( -
- Creating new account is disabled for now. Please use the self-hosted version. -
- ) : ( - - )} -
-
- ) -} diff --git a/app/api/stripe/checkout/route.ts b/app/api/stripe/checkout/route.ts new file mode 100644 index 0000000..5b09689 --- /dev/null +++ b/app/api/stripe/checkout/route.ts @@ -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 }) + } +} diff --git a/app/api/stripe/portal/route.ts b/app/api/stripe/portal/route.ts new file mode 100644 index 0000000..40f3b49 --- /dev/null +++ b/app/api/stripe/portal/route.ts @@ -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 }) + } +} diff --git a/app/api/stripe/webhook/route.ts b/app/api/stripe/webhook/route.ts new file mode 100644 index 0000000..2633b2e --- /dev/null +++ b/app/api/stripe/webhook/route.ts @@ -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 }) + } +} diff --git a/app/docs/ai/page.tsx b/app/docs/ai/page.tsx index 7317d32..18b5ddc 100644 --- a/app/docs/ai/page.tsx +++ b/app/docs/ai/page.tsx @@ -1,4 +1,4 @@ -export default function AI() { +export default async function AI() { return (

AI Use Disclosure

@@ -18,8 +18,9 @@ export default function AI() {

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

1. Purpose of AI in TaxHacker

@@ -52,7 +53,7 @@ export default function AI() {

3. Data Sent for AI Processing

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

  • Uploaded documents (e.g., receipts, invoices)
  • diff --git a/app/docs/cookie/page.tsx b/app/docs/cookie/page.tsx index f462aec..10daeab 100644 --- a/app/docs/cookie/page.tsx +++ b/app/docs/cookie/page.tsx @@ -1,4 +1,4 @@ -export default function Cookie() { +export default async function Cookie() { return (

    Cookie Policy

    diff --git a/app/docs/privacy_policy/page.tsx b/app/docs/privacy_policy/page.tsx index d7257e1..dfd964d 100644 --- a/app/docs/privacy_policy/page.tsx +++ b/app/docs/privacy_policy/page.tsx @@ -1,4 +1,4 @@ -export default function PrivacyPolicy() { +export default async function PrivacyPolicy() { return (

    @@ -25,8 +25,8 @@ export default function PrivacyPolicy() {

    - 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{" "} taxhacker.app @@ -202,7 +202,7 @@ export default function PrivacyPolicy() {

    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.

    ) diff --git a/app/docs/terms/page.tsx b/app/docs/terms/page.tsx index 1ec9a9d..773562c 100644 --- a/app/docs/terms/page.tsx +++ b/app/docs/terms/page.tsx @@ -1,4 +1,4 @@ -export default function Terms() { +export default async function Terms() { return (

    Terms of Service

    @@ -17,9 +17,9 @@ export default function 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. + 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.

    1. Service Overview

    @@ -118,7 +118,8 @@ export default function Terms() {

    7. Limitations of Liability

    • - TaxHacker is provided "as is", without warranties of any kind. + TaxHacker is provided "as is", without warranties of any + kind.
    • We make no guarantees about the accuracy of AI-generated outputs diff --git a/components/auth/login-form.tsx b/components/auth/login-form.tsx index d4aae5e..acb99b0 100644 --- a/components/auth/login-form.tsx +++ b/components/auth/login-form.tsx @@ -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) diff --git a/components/auth/pricing-card.tsx b/components/auth/pricing-card.tsx new file mode 100644 index 0000000..38670a7 --- /dev/null +++ b/components/auth/pricing-card.tsx @@ -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(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 ( + +
      + + {plan.name} + {plan.description} + {plan.price &&
      {plan.price}
      } +
      + +
        + {plan.benefits.map((benefit, index) => ( +
      • + + {benefit} +
      • + ))} +
      +
      + + {!hideButton && ( + + )} + {error && {error}} + + + ) +} diff --git a/components/auth/subscription-expired.tsx b/components/auth/subscription-expired.tsx new file mode 100644 index 0000000..a9ee392 --- /dev/null +++ b/components/auth/subscription-expired.tsx @@ -0,0 +1,12 @@ +import Link from "next/link" + +export function SubscriptionExpired() { + return ( + + Your subscription has expired. Click here to select a new plan. Otherwise, your account will be deleted. + + ) +} diff --git a/components/settings/profile-settings-form.tsx b/components/settings/profile-settings-form.tsx index e45c9c9..083f48c 100644 --- a/components/settings/profile-settings-form.tsx +++ b/components/settings/profile-settings-form.tsx @@ -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 && {saveState.error}} - -

      - Storage Used: {formatBytes(user.storageUsed)} /{" "} - {user.storageLimit > 0 ? formatBytes(user.storageLimit) : "Unlimited"} -

      -

      Tokens Balance: {formatNumber(user.tokenBalance)}

      -
      + +
      + +
      ) } diff --git a/components/settings/subscription-plan.tsx b/components/settings/subscription-plan.tsx new file mode 100644 index 0000000..eacecb0 --- /dev/null +++ b/components/settings/subscription-plan.tsx @@ -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 ( +
      +
      + + Current Plan +
      +
      + +
      + Usage: +
      + + + Storage: {formatBytes(user.storageUsed)} /{" "} + {user.storageLimit > 0 ? formatBytes(user.storageLimit) : "Unlimited"} + +
      +
      + + + AI Scans: {formatNumber(plan.limits.ai - user.aiBalance)} /{" "} + {plan.limits.ai > 0 ? formatNumber(plan.limits.ai) : "Unlimited"} + +
      +
      + + + Expiration Date:{" "} + {user.membershipExpiresAt ? formatDate(user.membershipExpiresAt, "yyyy-MM-dd") : "Never"} + +
      +
      + {user.stripeCustomerId && ( +
      + +
      + )} +
      +
      +
      + ) +} diff --git a/lib/auth-client.ts b/lib/auth-client.ts index 18531d3..739478b 100644 --- a/lib/auth-client.ts +++ b/lib/auth-client.ts @@ -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()], }) diff --git a/lib/auth.ts b/lib/auth.ts index aae418d..8fcb4f8 100644 --- a/lib/auth.ts +++ b/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 { // 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() +} diff --git a/lib/config.ts b/lib/config.ts index 5cb83e7..cfa8f15 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -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, diff --git a/lib/stripe.ts b/lib/stripe.ts index 65e620d..d281eb3 100644 --- a/lib/stripe.ts +++ b/lib/stripe.ts @@ -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 = { + 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, + }, } diff --git a/models/users.ts b/models/users.ts index caf61b2..a7e82d1 100644 --- a/models/users.ts +++ b/models/users.ts @@ -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 }, diff --git a/package-lock.json b/package-lock.json index 2e4333b..fe6f633 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index dd1b401..504366c 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/prisma/migrations/20250424103453_stripe_customer_id/migration.sql b/prisma/migrations/20250424103453_stripe_customer_id/migration.sql new file mode 100644 index 0000000..aee85ab --- /dev/null +++ b/prisma/migrations/20250424103453_stripe_customer_id/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 60277de..ba4c9b1 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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[]