feat: incomplete

This commit is contained in:
Vasily Zubarev
2025-05-18 12:17:06 +02:00
parent ee368180f6
commit 6d1eb199db
5 changed files with 434 additions and 0 deletions
+202
View File
@@ -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>
)
}
+32
View File
@@ -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: "🇲🇽" },
]
+7
View File
@@ -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: "💰",
}
+20
View File
@@ -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>
)
}