mirror of
https://github.com/vas3k/TaxHacker.git
synced 2026-05-02 20:39:10 -05:00
feat: incomplete
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
"use server"
|
||||
|
||||
import { analyzeTransaction } from "@/ai/analyze"
|
||||
import { getCurrentUser } from "@/lib/auth"
|
||||
import config from "@/lib/config"
|
||||
import { getAppData, setAppData } from "@/models/apps"
|
||||
import { getSettings } from "@/models/settings"
|
||||
import { getTransactions } from "@/models/transactions"
|
||||
import { User } from "@/prisma/client"
|
||||
import { revalidatePath } from "next/cache"
|
||||
|
||||
export type TaxAdviceRequest = {
|
||||
prompt: string
|
||||
countryCode?: string
|
||||
}
|
||||
|
||||
export type TaxRecommendation = {
|
||||
title: string
|
||||
description: string
|
||||
steps: string[]
|
||||
}
|
||||
|
||||
export type TaxAdviceResponse = {
|
||||
recommendations: TaxRecommendation[]
|
||||
problematicTransactions: {
|
||||
id: string
|
||||
name: string
|
||||
reason: string
|
||||
}[]
|
||||
}
|
||||
|
||||
// Define the type for stored app data
|
||||
export type TaxAdvisorAppData = {
|
||||
lastRequest: {
|
||||
prompt: string
|
||||
countryCode: string
|
||||
}
|
||||
lastResponse: TaxAdviceResponse
|
||||
}
|
||||
|
||||
// Define a schema for the LLM response
|
||||
const taxAdviceSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
recommendations: {
|
||||
type: "array",
|
||||
description: "List of tax recommendations with structured details",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
title: {
|
||||
type: "string",
|
||||
description: "A short, specific title for the recommendation",
|
||||
},
|
||||
description: {
|
||||
type: "string",
|
||||
description: "A detailed explanation of the tax recommendation",
|
||||
},
|
||||
steps: {
|
||||
type: "array",
|
||||
description: "A list of specific steps or actions to take for this recommendation",
|
||||
items: {
|
||||
type: "string",
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ["title", "description", "steps"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
problematicTransactions: {
|
||||
type: "array",
|
||||
description: "List of transactions that might be problematic for tax purposes",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: {
|
||||
type: "string",
|
||||
description: "ID of the transaction",
|
||||
},
|
||||
name: {
|
||||
type: "string",
|
||||
description: "Name of the transaction",
|
||||
},
|
||||
reason: {
|
||||
type: "string",
|
||||
description: "Reason why this transaction is problematic for tax purposes",
|
||||
},
|
||||
},
|
||||
required: ["id", "name", "reason"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ["recommendations", "problematicTransactions"],
|
||||
additionalProperties: false,
|
||||
}
|
||||
|
||||
export async function getLastTaxAdviceData(user: User): Promise<TaxAdvisorAppData | null> {
|
||||
const appData = (await getAppData(user, "taxadvisor")) as TaxAdvisorAppData | null
|
||||
return appData
|
||||
}
|
||||
|
||||
export async function saveTaxAdviceData(
|
||||
user: User,
|
||||
request: TaxAdviceRequest,
|
||||
response: TaxAdviceResponse
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const appData: TaxAdvisorAppData = {
|
||||
lastRequest: {
|
||||
prompt: request.prompt,
|
||||
countryCode: request.countryCode || "us",
|
||||
},
|
||||
lastResponse: response,
|
||||
}
|
||||
|
||||
await setAppData(user, "taxadvisor", appData)
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error("Failed to save tax advice data:", error)
|
||||
return {
|
||||
success: false,
|
||||
error: `Failed to save tax advice data: ${error}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTransactionTaxAdvice(
|
||||
request: TaxAdviceRequest
|
||||
): Promise<{ success: boolean; data?: TaxAdviceResponse; error?: string }> {
|
||||
try {
|
||||
const user = await getCurrentUser()
|
||||
const { transactions } = await getTransactions(user.id)
|
||||
const settings = await getSettings(user.id)
|
||||
|
||||
const apiKey = settings.openai_api_key || config.ai.openaiApiKey || ""
|
||||
if (!apiKey) {
|
||||
return {
|
||||
success: false,
|
||||
error: "OpenAI API key not found in settings. Please set up your API key first.",
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare transaction data for the LLM
|
||||
const transactionData = transactions.map((tx) => ({
|
||||
id: tx.id,
|
||||
date: tx.issuedAt?.toISOString().slice(0, 10) || "unknown",
|
||||
name: tx.name || "Unnamed",
|
||||
merchant: tx.merchant || "Unknown",
|
||||
description: tx.description || "",
|
||||
amount: tx.total ? tx.total / 100 : 0,
|
||||
currency: tx.currencyCode || "Unknown",
|
||||
type: tx.type || "expense",
|
||||
categoryCode: tx.categoryCode || "unknown",
|
||||
projectCode: tx.projectCode || "unknown",
|
||||
}))
|
||||
|
||||
// Base prompt from request
|
||||
let finalPrompt = request.prompt || ""
|
||||
|
||||
// Add transaction context to the prompt
|
||||
finalPrompt += "\n\nHere are my transactions:\n" + JSON.stringify(transactionData, null, 2)
|
||||
|
||||
// Instructions for format
|
||||
finalPrompt +=
|
||||
"\n\nPlease provide your analysis as JSON with structured recommendations (including title, description, and steps) and a list of problematic transactions."
|
||||
|
||||
// Call the LLM API through the analyze function
|
||||
const response = await analyzeTransaction(
|
||||
finalPrompt,
|
||||
taxAdviceSchema,
|
||||
[], // No attachments needed
|
||||
apiKey
|
||||
)
|
||||
|
||||
if (!response.success || !response.data) {
|
||||
return {
|
||||
success: false,
|
||||
error: response.error || "Failed to process request",
|
||||
}
|
||||
}
|
||||
|
||||
const llmResponse = response.data.output as unknown as TaxAdviceResponse
|
||||
|
||||
// Save the request and response to app data
|
||||
await saveTaxAdviceData(user, request, llmResponse)
|
||||
|
||||
revalidatePath("/apps/taxadvisor")
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: llmResponse,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to get tax advice:", error)
|
||||
return {
|
||||
success: false,
|
||||
error: `Failed to get tax advice: ${error}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
"use client"
|
||||
|
||||
import { FormSelect } from "@/components/forms/simple"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { User } from "@/prisma/client"
|
||||
import { Loader2 } from "lucide-react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { getLastTaxAdviceData, getTransactionTaxAdvice, TaxAdviceResponse } from "../actions"
|
||||
import { countries } from "../countries"
|
||||
|
||||
const DEFAULT_PROMPT = `Please analyze my transactions and provide tax advice.
|
||||
Consider the following questions:
|
||||
1. Are there any transactions that might be problematic during a tax audit?
|
||||
2. What tax deductions am I missing?
|
||||
3. How can I better categorize my expenses for tax purposes?
|
||||
4. Any recommendations for tax optimization?`
|
||||
|
||||
export function TaxAdvisorComponent({ user }: { user: User }) {
|
||||
const [prompt, setPrompt] = useState(DEFAULT_PROMPT)
|
||||
const [countryCode, setCountryCode] = useState<string>("us")
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isInitialized, setIsInitialized] = useState(false)
|
||||
const [isLoadingData, setIsLoadingData] = useState(true)
|
||||
const [result, setResult] = useState<TaxAdviceResponse | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Format countries for the FormSelect component
|
||||
const countryOptions = countries.map((country) => ({
|
||||
code: country.code,
|
||||
name: `${country.flag} ${country.name}`,
|
||||
}))
|
||||
|
||||
// Load saved data on component mount
|
||||
useEffect(() => {
|
||||
async function loadSavedData() {
|
||||
try {
|
||||
const savedData = await getLastTaxAdviceData(user)
|
||||
if (savedData) {
|
||||
setPrompt(savedData.lastRequest.prompt)
|
||||
setCountryCode(savedData.lastRequest.countryCode)
|
||||
setResult(savedData.lastResponse)
|
||||
setIsInitialized(true)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error loading saved data:", err)
|
||||
} finally {
|
||||
setIsLoadingData(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadSavedData()
|
||||
}, [user])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
// Get the selected country details
|
||||
const selectedCountry = countries.find((c) => c.code === countryCode)
|
||||
|
||||
// Create the final prompt with country information
|
||||
let finalPrompt = prompt
|
||||
if (selectedCountry) {
|
||||
finalPrompt = `${finalPrompt}\n\nPlease consider the tax laws and regulations specific to ${selectedCountry.name} ${selectedCountry.flag} when providing advice.`
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await getTransactionTaxAdvice({
|
||||
prompt: finalPrompt,
|
||||
countryCode: countryCode,
|
||||
})
|
||||
|
||||
if (response.success && response.data) {
|
||||
setResult(response.data)
|
||||
setIsInitialized(true)
|
||||
} else {
|
||||
setError(response.error || "Failed to get tax advice")
|
||||
}
|
||||
} catch (err) {
|
||||
setError(`Error: ${err}`)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoadingData) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-gray-500" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
<FormSelect
|
||||
items={countryOptions}
|
||||
title="Select country for tax advice"
|
||||
placeholder="Select a country"
|
||||
value={countryCode}
|
||||
onValueChange={setCountryCode}
|
||||
/>
|
||||
|
||||
<textarea
|
||||
id="prompt"
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
className="w-full min-h-[200px] p-4 border border-gray-300 rounded-md"
|
||||
placeholder="Enter your question about taxes and transactions..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" disabled={isLoading} className="w-full md:w-auto">
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Analyzing...
|
||||
</>
|
||||
) : (
|
||||
"Ask Tax Advisor"
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{error && <div className="p-4 bg-red-50 border border-red-200 rounded-md text-red-800">{error}</div>}
|
||||
|
||||
{isInitialized && result && (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="bg-gray-50 p-6 rounded-lg border border-gray-200">
|
||||
<h3 className="text-xl font-semibold mb-4">Tax Recommendations</h3>
|
||||
|
||||
{result.recommendations.map((recommendation, index) => (
|
||||
<div key={index} className="mb-6 last:mb-0">
|
||||
<h4 className="text-lg font-semibold mb-2">{recommendation.title}</h4>
|
||||
<p className="text-gray-700 mb-3">{recommendation.description}</p>
|
||||
|
||||
{recommendation.steps.length > 0 && (
|
||||
<div className="pl-4 border-l-2 border-gray-200">
|
||||
<h5 className="font-medium mb-1">Steps to Take:</h5>
|
||||
<ul className="list-disc pl-5">
|
||||
{recommendation.steps.map((step, stepIndex) => (
|
||||
<li key={stepIndex} className="mb-1">
|
||||
{step}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{result.problematicTransactions.length > 0 && (
|
||||
<div className="bg-gray-50 p-6 rounded-lg border border-gray-200">
|
||||
<h3 className="text-xl font-semibold mb-4">Problematic Transactions</h3>
|
||||
<div className="divide-y divide-gray-200">
|
||||
{result.problematicTransactions.map((tx) => (
|
||||
<div key={tx.id} className="py-4">
|
||||
<div className="font-medium">{tx.name}</div>
|
||||
<div className="text-sm text-red-600">{tx.reason}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
export type Country = {
|
||||
code: string
|
||||
name: string
|
||||
flag: string
|
||||
}
|
||||
|
||||
export const countries: Country[] = [
|
||||
{ code: "us", name: "United States", flag: "🇺🇸" },
|
||||
{ code: "uk", name: "United Kingdom", flag: "🇬🇧" },
|
||||
{ code: "ca", name: "Canada", flag: "🇨🇦" },
|
||||
{ code: "au", name: "Australia", flag: "🇦🇺" },
|
||||
{ code: "de", name: "Germany", flag: "🇩🇪" },
|
||||
{ code: "fr", name: "France", flag: "🇫🇷" },
|
||||
{ code: "es", name: "Spain", flag: "🇪🇸" },
|
||||
{ code: "it", name: "Italy", flag: "🇮🇹" },
|
||||
{ code: "nl", name: "Netherlands", flag: "🇳🇱" },
|
||||
{ code: "ch", name: "Switzerland", flag: "🇨🇭" },
|
||||
{ code: "sg", name: "Singapore", flag: "🇸🇬" },
|
||||
{ code: "jp", name: "Japan", flag: "🇯🇵" },
|
||||
{ code: "cn", name: "China", flag: "🇨🇳" },
|
||||
{ code: "in", name: "India", flag: "🇮🇳" },
|
||||
{ code: "br", name: "Brazil", flag: "🇧🇷" },
|
||||
{ code: "ru", name: "Russia", flag: "🇷🇺" },
|
||||
{ code: "se", name: "Sweden", flag: "🇸🇪" },
|
||||
{ code: "no", name: "Norway", flag: "🇳🇴" },
|
||||
{ code: "dk", name: "Denmark", flag: "🇩🇰" },
|
||||
{ code: "fi", name: "Finland", flag: "🇫🇮" },
|
||||
{ code: "ie", name: "Ireland", flag: "🇮🇪" },
|
||||
{ code: "nz", name: "New Zealand", flag: "🇳🇿" },
|
||||
{ code: "ae", name: "UAE", flag: "🇦🇪" },
|
||||
{ code: "mx", name: "Mexico", flag: "🇲🇽" },
|
||||
]
|
||||
@@ -0,0 +1,7 @@
|
||||
import { AppManifest } from "../common"
|
||||
|
||||
export const manifest: AppManifest = {
|
||||
name: "Tax Advisor",
|
||||
description: "Get tax advice and recommendations based on your transactions",
|
||||
icon: "💰",
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { getCurrentUser } from "@/lib/auth"
|
||||
import { TaxAdvisorComponent } from "./components/tax-advisor"
|
||||
import { manifest } from "./manifest"
|
||||
|
||||
export default async function TaxAdvisorApp() {
|
||||
const user = await getCurrentUser()
|
||||
|
||||
return (
|
||||
<div>
|
||||
<header className="flex flex-wrap items-center justify-between gap-2 mb-8">
|
||||
<h2 className="flex flex-row gap-3 md:gap-5">
|
||||
<span className="text-3xl font-bold tracking-tight">
|
||||
{manifest.icon} {manifest.name}
|
||||
</span>
|
||||
</h2>
|
||||
</header>
|
||||
<TaxAdvisorComponent user={user} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user