Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
d590f8d
fix: improve LLM provider error messages and update default model names
adryserage Jan 17, 2026
0c9743c
feat: add dynamic model fetching from provider APIs
adryserage Jan 17, 2026
cad5b6b
fix: resolve timezone-related date shift issue
adryserage Jan 17, 2026
36a298c
chore: remove unused error variable in catch block
adryserage Jan 17, 2026
2c2f3d4
feat: add duplicate/copy transaction feature
adryserage Jan 17, 2026
1d9138d
feat: add Ollama support for local LLM processing
adryserage Jan 17, 2026
d86ef37
feat: add drag-and-drop field reordering (#12)
adryserage Jan 17, 2026
d5dc530
feat: add SKIP_DB_CHECK env variable to bypass database readiness che…
adryserage Jan 17, 2026
7d236ec
feat: add SHIFT+click range selection for transactions (#27)
adryserage Jan 17, 2026
7f3a518
feat: add sub-categories support (#6)
adryserage Jan 17, 2026
ed1798e
feat: add tax year filter (#44)
adryserage Jan 17, 2026
3c05311
feat: show recognized text in analyze screen (#42)
adryserage Jan 17, 2026
25ed5b5
Refactor docker-compose.yml for better configuration
adryserage Jan 18, 2026
a2a8fac
fix: resolve Node.js version and File type compatibility issues
adryserage Jan 18, 2026
a6b256d
feat: add SQLite database support as alternative to PostgreSQL
adryserage Jan 18, 2026
4968979
Merge branch 'hotfix/timezone-date-fix'
adryserage Jan 18, 2026
ffd2d07
Merge branch 'feature/sqlite-support'
adryserage Jan 18, 2026
0b2b294
Merge branch 'feature/copy-transaction'
adryserage Jan 18, 2026
df8fc61
Merge branch 'feature/dynamic-model-fetcher'
adryserage Jan 18, 2026
c1b6947
Merge branch 'feature/mass-selection'
adryserage Jan 18, 2026
e9022f6
Merge branch 'feature/ollama-support'
adryserage Jan 18, 2026
f77bf2a
Merge branch 'feature/recognized-text'
adryserage Jan 18, 2026
d5f1519
Merge branch 'feature/reorder-fields'
adryserage Jan 18, 2026
f00a510
Merge feature/skip-db-check with conflict resolution
adryserage Jan 18, 2026
83b26bd
Merge branch 'feature/subcategories'
adryserage Jan 18, 2026
4557b02
Merge branch 'feature/tax-year-filter'
adryserage Jan 18, 2026
58a9281
fix: remove npm from nixpacks config (included with nodejs)
adryserage Jan 18, 2026
e153c10
fix: add database compatibility layer for SQLite support
adryserage Jan 18, 2026
73af17e
fix: disable Next.js telemetry and fix start script for SQLite
adryserage Jan 18, 2026
bafaee6
fix: explicitly disable Next.js telemetry before build
adryserage Jan 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,35 @@ SELF_HOSTED_MODE=true
DISABLE_SIGNUP=true

UPLOAD_PATH="./data/uploads"
DATABASE_URL="postgresql://user@localhost:5432/taxhacker"

# Database Configuration
# Option 1: PostgreSQL (recommended for production)
DATABASE_URL="postgresql://user:password@localhost:5432/taxhacker"

# Option 2: SQLite (simpler, single-file database)
# DATABASE_URL="file:./data/taxhacker.db"

# Optional: Explicitly set database provider (auto-detected from DATABASE_URL)
# DATABASE_PROVIDER="postgresql" # or "sqlite"

# Set to true to skip database readiness check on startup
# Useful when using external PostgreSQL or if the check fails due to URL parsing issues
SKIP_DB_CHECK=false

# You can put it here or the app will ask you to enter it
OPENAI_MODEL_NAME="gpt-4o-mini"
OPENAI_API_KEY="" # "sk-..."

GOOGLE_MODEL_NAME="gemini-2.5-flash"
GOOGLE_MODEL_NAME="gemini-2.0-flash"
GOOGLE_API_KEY=""

MISTRAL_MODEL_NAME="mistral-medium-latest"
MISTRAL_API_KEY=""

# Ollama (Local LLM) Configuration - requires a vision-capable model
OLLAMA_MODEL_NAME="llava"
OLLAMA_BASE_URL="http://localhost:11434" # Ollama server URL

# Auth Config
BETTER_AUTH_SECRET="random-secret-key" # please use any long random string here

Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/docker-latest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,5 +43,5 @@ jobs:
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
cache-from: type=gha,scope=node23
cache-to: type=gha,mode=max,scope=node23
4 changes: 2 additions & 2 deletions .github/workflows/docker-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,5 +44,5 @@ jobs:
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
cache-from: type=gha,scope=node23
cache-to: type=gha,mode=max,scope=node23
1 change: 1 addition & 0 deletions .nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
22
6 changes: 4 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ FROM node:23-slim AS base
# Default environment variables
ENV PORT=7331
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1

# Build stage
FROM base AS builder
Expand All @@ -22,8 +23,8 @@ RUN npm ci
# Copy source code
COPY . .

# Build the application
RUN npm run build
# Disable Next.js telemetry and build
RUN npx next telemetry disable && npm run build

# Production stage
FROM base
Expand All @@ -47,6 +48,7 @@ RUN mkdir -p /app/upload
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package*.json ./
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/scripts ./scripts
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public
COPY --from=builder /app/app ./app
Expand Down
79 changes: 74 additions & 5 deletions ai/providers/llmProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { ChatGoogleGenerativeAI } from "@langchain/google-genai"
import { ChatMistralAI } from "@langchain/mistralai"
import { BaseMessage, HumanMessage } from "@langchain/core/messages"

export type LLMProvider = "openai" | "google" | "mistral"
export type LLMProvider = "openai" | "google" | "mistral" | "ollama"

export interface LLMConfig {
provider: LLMProvider
Expand All @@ -28,6 +28,44 @@ export interface LLMResponse {
error?: string
}

// Known valid model names for each provider (for error messages)
const VALID_MODELS: Record<LLMProvider, string[]> = {
openai: ["gpt-4o", "gpt-4o-mini", "gpt-4-turbo", "gpt-4", "gpt-3.5-turbo"],
google: ["gemini-2.0-flash", "gemini-2.0-flash-lite", "gemini-1.5-flash", "gemini-1.5-pro", "gemini-2.5-flash"],
mistral: ["mistral-large-latest", "mistral-medium-latest", "mistral-small-latest", "open-mistral-nemo"],
ollama: ["llava", "llava:13b", "llama3.2-vision", "bakllava", "moondream"],
}

function formatModelError(provider: LLMProvider, model: string, originalError: string): string {
const validModels = VALID_MODELS[provider]

// Check for common error patterns and provide helpful messages
if (
originalError.includes("does not support images") ||
originalError.includes("not found") ||
originalError.includes("model not found") ||
originalError.includes("Invalid model") ||
originalError.includes("404")
) {
return `Model "${model}" for ${provider} is invalid or does not exist. Valid models include: ${validModels.join(", ")}. Please check your settings.`
}

if (originalError.includes("API key") || originalError.includes("authentication") || originalError.includes("401")) {
return `Invalid API key for ${provider}. Please verify your API key in settings.`
}

if (originalError.includes("rate limit") || originalError.includes("429")) {
return `Rate limit exceeded for ${provider}. Please try again later.`
}

if (originalError.includes("quota") || originalError.includes("insufficient")) {
return `Quota exceeded for ${provider}. Please check your billing/quota settings.`
}

// Return original error with model context
return `${provider} (model: ${model}) error: ${originalError}`
}

async function requestLLMUnified(config: LLMConfig, req: LLMRequest): Promise<LLMResponse> {
try {
const temperature = 0
Expand All @@ -50,6 +88,18 @@ async function requestLLMUnified(config: LLMConfig, req: LLMRequest): Promise<LL
model: config.model,
temperature: temperature,
})
} else if (config.provider === "ollama") {
// Ollama uses OpenAI-compatible API
// config.apiKey contains the base URL (e.g., http://localhost:11434)
const baseUrl = config.apiKey.endsWith("/") ? config.apiKey.slice(0, -1) : config.apiKey
model = new ChatOpenAI({
configuration: {
baseURL: `${baseUrl}/v1`,
},
apiKey: "ollama", // Ollama doesn't require a real API key
model: config.model,
temperature: temperature,
})
} else {
return {
output: {},
Expand Down Expand Up @@ -79,34 +129,53 @@ async function requestLLMUnified(config: LLMConfig, req: LLMRequest): Promise<LL
provider: config.provider,
}
} catch (error: any) {
const originalError = error instanceof Error ? error.message : `${config.provider} request failed`
const formattedError = formatModelError(config.provider, config.model, originalError)

return {
output: {},
provider: config.provider,
error: error instanceof Error ? error.message : `${config.provider} request failed`,
error: formattedError,
}
}
}

export async function requestLLM(settings: LLMSettings, req: LLMRequest): Promise<LLMResponse> {
const errors: string[] = []

for (const config of settings.providers) {
if (!config.apiKey || !config.model) {
console.info("Skipping provider:", config.provider)
console.info("Skipping provider:", config.provider, "(no API key or model configured)")
continue
}
console.info("Use provider:", config.provider)
console.info("Use provider:", config.provider, "with model:", config.model)

const response = await requestLLMUnified(config, req)

if (!response.error) {
return response
} else {
console.error(response.error)
errors.push(response.error)
}
}

// Build a helpful error message
const configuredProviders = settings.providers.filter(p => p.apiKey && p.model)

if (configuredProviders.length === 0) {
return {
output: {},
provider: settings.providers[0]?.provider || "openai",
error: "No LLM providers configured. Please add an API key and model name in Settings > LLM Providers.",
}
}

// Include specific errors for each failed provider
const errorDetails = errors.length > 0 ? ` Errors: ${errors.join(" | ")}` : ""
return {
output: {},
provider: settings.providers[0]?.provider || "openai",
error: "All LLM providers failed or are not configured",
error: `All LLM providers failed.${errorDetails}`,
}
}
4 changes: 2 additions & 2 deletions app/(app)/apps/invoices/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,14 @@ export async function generateInvoicePDF(data: InvoiceFormData): Promise<Uint8Ar
}

export async function addNewTemplateAction(user: User, template: InvoiceTemplate) {
const appData = (await getAppData(user, "invoices")) as InvoiceAppData | null
const appData = await getAppData<InvoiceAppData>(user, "invoices")
const updatedTemplates = [...(appData?.templates || []), template]
const appDataResult = await setAppData(user, "invoices", { ...appData, templates: updatedTemplates })
return { success: true, data: appDataResult }
}

export async function deleteTemplateAction(user: User, templateId: string) {
const appData = (await getAppData(user, "invoices")) as InvoiceAppData | null
const appData = await getAppData<InvoiceAppData>(user, "invoices")
if (!appData) return { success: false, error: "No app data found" }

const updatedTemplates = appData.templates.filter((t) => t.id !== templateId)
Expand Down
2 changes: 1 addition & 1 deletion app/(app)/apps/invoices/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export default async function InvoicesApp() {
const user = await getCurrentUser()
const settings = await getSettings(user.id)
const currencies = await getCurrencies(user.id)
const appData = (await getAppData(user, "invoices")) as InvoiceAppData | null
const appData = await getAppData<InvoiceAppData>(user, "invoices")

return (
<div>
Expand Down
15 changes: 12 additions & 3 deletions app/(app)/files/actions.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
"use server"

// File-like interface for form data uploads (avoids runtime File check issues in Node.js)
interface UploadedFile {
name: string
size: number
type: string
lastModified: number
arrayBuffer(): Promise<ArrayBuffer>
}

import { ActionState } from "@/lib/actions"
import { getCurrentUser, isSubscriptionExpired } from "@/lib/auth"
import {
Expand All @@ -18,13 +27,13 @@ import path from "path"

export async function uploadFilesAction(formData: FormData): Promise<ActionState<null>> {
const user = await getCurrentUser()
const files = formData.getAll("files") as File[]
const files = formData.getAll("files") as UploadedFile[]

// Make sure upload dir exists
const userUploadsDirectory = getUserUploadsDirectory(user)

// Check limits
const totalFileSize = files.reduce((acc, file) => acc + file.size, 0)
const totalFileSize = files.reduce((acc, file) => acc + (file?.size || 0), 0)
if (!isEnoughStorageToUploadFile(user, totalFileSize)) {
return { success: false, error: `Insufficient storage to upload these files` }
}
Expand All @@ -39,7 +48,7 @@ export async function uploadFilesAction(formData: FormData): Promise<ActionState
// Process each file
const uploadedFiles = await Promise.all(
files.map(async (file) => {
if (!(file instanceof File)) {
if (!file || typeof file !== "object" || !("size" in file) || !("arrayBuffer" in file)) {
return { success: false, error: "Invalid file" }
}

Expand Down
10 changes: 9 additions & 1 deletion app/(app)/import/csv/actions.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
"use server"

// File-like interface for form data uploads (avoids runtime File check issues in Node.js)
interface UploadedFile {
name: string
size: number
type: string
arrayBuffer(): Promise<ArrayBuffer>
}

import { ActionState } from "@/lib/actions"
import { getCurrentUser } from "@/lib/auth"
import { EXPORT_AND_IMPORT_FIELD_MAP } from "@/models/export_and_import"
Expand All @@ -12,7 +20,7 @@ export async function parseCSVAction(
_prevState: ActionState<string[][]> | null,
formData: FormData
): Promise<ActionState<string[][]>> {
const file = formData.get("file") as File
const file = formData.get("file") as UploadedFile | null
if (!file) {
return { success: false, error: "No file uploaded" }
}
Expand Down
26 changes: 21 additions & 5 deletions app/(app)/settings/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { uploadStaticImage } from "@/lib/uploads"
import { codeFromName, randomHexColor } from "@/lib/utils"
import { createCategory, deleteCategory, updateCategory } from "@/models/categories"
import { createCurrency, deleteCurrency, updateCurrency } from "@/models/currencies"
import { createField, deleteField, updateField } from "@/models/fields"
import { createField, deleteField, updateField, updateFieldOrders } from "@/models/fields"
import { createProject, deleteProject, updateProject } from "@/models/projects"
import { SettingsMap, updateSettings } from "@/models/settings"
import { updateUser } from "@/models/users"
Expand Down Expand Up @@ -57,8 +57,8 @@ export async function saveProfileAction(

// Upload avatar
let avatarUrl = user.avatar
const avatarFile = formData.get("avatar") as File | null
if (avatarFile instanceof File && avatarFile.size > 0) {
const avatarFile = formData.get("avatar")
if (avatarFile && typeof avatarFile === "object" && "size" in avatarFile && (avatarFile as Blob).size > 0) {
try {
const uploadedAvatarPath = await uploadStaticImage(user, avatarFile, "avatar.webp", 500, 500)
avatarUrl = `/files/static/${path.basename(uploadedAvatarPath)}`
Expand All @@ -69,8 +69,8 @@ export async function saveProfileAction(

// Upload business logo
let businessLogoUrl = user.businessLogo
const businessLogoFile = formData.get("businessLogo") as File | null
if (businessLogoFile instanceof File && businessLogoFile.size > 0) {
const businessLogoFile = formData.get("businessLogo")
if (businessLogoFile && typeof businessLogoFile === "object" && "size" in businessLogoFile && (businessLogoFile as Blob).size > 0) {
try {
const uploadedBusinessLogoPath = await uploadStaticImage(user, businessLogoFile, "businessLogo.png", 500, 500)
businessLogoUrl = `/files/static/${path.basename(uploadedBusinessLogoPath)}`
Expand Down Expand Up @@ -195,6 +195,7 @@ export async function addCategoryAction(userId: string, data: Prisma.CategoryCre
name: validatedForm.data.name,
llm_prompt: validatedForm.data.llm_prompt,
color: validatedForm.data.color || "",
parentCode: validatedForm.data.parentCode || null,
})
revalidatePath("/settings/categories")

Expand All @@ -221,6 +222,7 @@ export async function editCategoryAction(userId: string, code: string, data: Pri
name: validatedForm.data.name,
llm_prompt: validatedForm.data.llm_prompt,
color: validatedForm.data.color || "",
parentCode: validatedForm.data.parentCode || null,
})
revalidatePath("/settings/categories")

Expand Down Expand Up @@ -288,3 +290,17 @@ export async function deleteFieldAction(userId: string, code: string) {
revalidatePath("/settings/fields")
return { success: true }
}

export async function reorderFieldsAction(
fieldOrders: { code: string; order: number }[]
): Promise<ActionState<void>> {
try {
const user = await getCurrentUser()
await updateFieldOrders(user.id, fieldOrders)
revalidatePath("/settings/fields")
return { success: true }
} catch (error) {
console.error("Failed to reorder fields:", error)
return { success: false, error: "Failed to reorder fields" }
}
}
10 changes: 9 additions & 1 deletion app/(app)/settings/backups/actions.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
"use server"

// File-like interface for form data uploads (avoids runtime File check issues in Node.js)
interface UploadedFile {
name: string
size: number
type: string
arrayBuffer(): Promise<ArrayBuffer>
}

import { ActionState } from "@/lib/actions"
import { getCurrentUser } from "@/lib/auth"
import { prisma } from "@/lib/db"
Expand All @@ -23,7 +31,7 @@ export async function restoreBackupAction(
): Promise<ActionState<BackupRestoreResult>> {
const user = await getCurrentUser()
const userUploadsDirectory = getUserUploadsDirectory(user)
const file = formData.get("file") as File
const file = formData.get("file") as UploadedFile | null

if (!file || file.size === 0) {
return { success: false, error: "No file provided" }
Expand Down
Loading