mirror of
https://github.com/marcogll/TaxHacker_s23.git
synced 2026-01-13 13:25:18 +00:00
324 lines
9.8 KiB
TypeScript
324 lines
9.8 KiB
TypeScript
import { prisma } from "@/lib/db"
|
|
import { calcTotalPerCurrency } from "@/lib/stats"
|
|
import { Prisma } from "@/prisma/client"
|
|
import { cache } from "react"
|
|
import { TransactionFilters } from "./transactions"
|
|
|
|
export type DashboardStats = {
|
|
totalIncomePerCurrency: Record<string, number>
|
|
totalExpensesPerCurrency: Record<string, number>
|
|
profitPerCurrency: Record<string, number>
|
|
invoicesProcessed: number
|
|
}
|
|
|
|
export const getDashboardStats = cache(
|
|
async (userId: string, filters: TransactionFilters = {}): Promise<DashboardStats> => {
|
|
const where: Prisma.TransactionWhereInput = {}
|
|
|
|
if (filters.dateFrom || filters.dateTo) {
|
|
where.issuedAt = {
|
|
gte: filters.dateFrom ? new Date(filters.dateFrom) : undefined,
|
|
lte: filters.dateTo ? new Date(filters.dateTo) : undefined,
|
|
}
|
|
}
|
|
|
|
const transactions = await prisma.transaction.findMany({ where: { ...where, userId } })
|
|
const totalIncomePerCurrency = calcTotalPerCurrency(transactions.filter((t) => t.type === "income"))
|
|
const totalExpensesPerCurrency = calcTotalPerCurrency(transactions.filter((t) => t.type === "expense"))
|
|
const profitPerCurrency = Object.fromEntries(
|
|
Object.keys(totalIncomePerCurrency).map((currency) => [
|
|
currency,
|
|
totalIncomePerCurrency[currency] - totalExpensesPerCurrency[currency],
|
|
])
|
|
)
|
|
const invoicesProcessed = transactions.length
|
|
|
|
return {
|
|
totalIncomePerCurrency,
|
|
totalExpensesPerCurrency,
|
|
profitPerCurrency,
|
|
invoicesProcessed,
|
|
}
|
|
}
|
|
)
|
|
|
|
export type ProjectStats = {
|
|
totalIncomePerCurrency: Record<string, number>
|
|
totalExpensesPerCurrency: Record<string, number>
|
|
profitPerCurrency: Record<string, number>
|
|
invoicesProcessed: number
|
|
}
|
|
|
|
export const getProjectStats = cache(async (userId: string, projectId: string, filters: TransactionFilters = {}) => {
|
|
const where: Prisma.TransactionWhereInput = {
|
|
projectCode: projectId,
|
|
}
|
|
|
|
if (filters.dateFrom || filters.dateTo) {
|
|
where.issuedAt = {
|
|
gte: filters.dateFrom ? new Date(filters.dateFrom) : undefined,
|
|
lte: filters.dateTo ? new Date(filters.dateTo) : undefined,
|
|
}
|
|
}
|
|
|
|
const transactions = await prisma.transaction.findMany({ where: { ...where, userId } })
|
|
const totalIncomePerCurrency = calcTotalPerCurrency(transactions.filter((t) => t.type === "income"))
|
|
const totalExpensesPerCurrency = calcTotalPerCurrency(transactions.filter((t) => t.type === "expense"))
|
|
const profitPerCurrency = Object.fromEntries(
|
|
Object.keys(totalIncomePerCurrency).map((currency) => [
|
|
currency,
|
|
totalIncomePerCurrency[currency] - totalExpensesPerCurrency[currency],
|
|
])
|
|
)
|
|
|
|
const invoicesProcessed = transactions.length
|
|
return {
|
|
totalIncomePerCurrency,
|
|
totalExpensesPerCurrency,
|
|
profitPerCurrency,
|
|
invoicesProcessed,
|
|
}
|
|
})
|
|
|
|
export type TimeSeriesData = {
|
|
period: string
|
|
income: number
|
|
expenses: number
|
|
date: Date
|
|
}
|
|
|
|
export type CategoryBreakdown = {
|
|
code: string
|
|
name: string
|
|
color: string
|
|
income: number
|
|
expenses: number
|
|
transactionCount: number
|
|
}
|
|
|
|
export type DetailedTimeSeriesData = {
|
|
period: string
|
|
income: number
|
|
expenses: number
|
|
date: Date
|
|
categories: CategoryBreakdown[]
|
|
totalTransactions: number
|
|
}
|
|
|
|
export const getTimeSeriesStats = cache(
|
|
async (
|
|
userId: string,
|
|
filters: TransactionFilters = {},
|
|
defaultCurrency: string = "EUR"
|
|
): Promise<TimeSeriesData[]> => {
|
|
const where: Prisma.TransactionWhereInput = { userId }
|
|
|
|
if (filters.dateFrom || filters.dateTo) {
|
|
where.issuedAt = {
|
|
gte: filters.dateFrom ? new Date(filters.dateFrom) : undefined,
|
|
lte: filters.dateTo ? new Date(filters.dateTo) : undefined,
|
|
}
|
|
}
|
|
|
|
if (filters.categoryCode) {
|
|
where.categoryCode = filters.categoryCode
|
|
}
|
|
|
|
if (filters.projectCode) {
|
|
where.projectCode = filters.projectCode
|
|
}
|
|
|
|
if (filters.type) {
|
|
where.type = filters.type
|
|
}
|
|
|
|
const transactions = await prisma.transaction.findMany({
|
|
where,
|
|
orderBy: { issuedAt: "asc" },
|
|
})
|
|
|
|
if (transactions.length === 0) {
|
|
return []
|
|
}
|
|
|
|
// Determine if we should group by day or month
|
|
const dateFrom = filters.dateFrom ? new Date(filters.dateFrom) : new Date(transactions[0].issuedAt!)
|
|
const dateTo = filters.dateTo ? new Date(filters.dateTo) : new Date(transactions[transactions.length - 1].issuedAt!)
|
|
const daysDiff = Math.ceil((dateTo.getTime() - dateFrom.getTime()) / (1000 * 60 * 60 * 24))
|
|
const groupByDay = daysDiff <= 50
|
|
|
|
// Group transactions by time period
|
|
const grouped = transactions.reduce(
|
|
(acc, transaction) => {
|
|
if (!transaction.issuedAt) return acc
|
|
|
|
const date = new Date(transaction.issuedAt)
|
|
const period = groupByDay
|
|
? date.toISOString().split("T")[0] // YYYY-MM-DD
|
|
: `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}` // YYYY-MM
|
|
|
|
if (!acc[period]) {
|
|
acc[period] = { period, income: 0, expenses: 0, date }
|
|
}
|
|
|
|
// Get amount in default currency
|
|
const amount =
|
|
transaction.convertedCurrencyCode?.toUpperCase() === defaultCurrency.toUpperCase()
|
|
? transaction.convertedTotal || 0
|
|
: transaction.currencyCode?.toUpperCase() === defaultCurrency.toUpperCase()
|
|
? transaction.total || 0
|
|
: 0 // Skip transactions not in default currency for simplicity
|
|
|
|
if (transaction.type === "income") {
|
|
acc[period].income += amount
|
|
} else if (transaction.type === "expense") {
|
|
acc[period].expenses += amount
|
|
}
|
|
|
|
return acc
|
|
},
|
|
{} as Record<string, TimeSeriesData>
|
|
)
|
|
|
|
return Object.values(grouped).sort((a, b) => a.date.getTime() - b.date.getTime())
|
|
}
|
|
)
|
|
|
|
export const getDetailedTimeSeriesStats = cache(
|
|
async (
|
|
userId: string,
|
|
filters: TransactionFilters = {},
|
|
defaultCurrency: string = "EUR"
|
|
): Promise<DetailedTimeSeriesData[]> => {
|
|
const where: Prisma.TransactionWhereInput = { userId }
|
|
|
|
if (filters.dateFrom || filters.dateTo) {
|
|
where.issuedAt = {
|
|
gte: filters.dateFrom ? new Date(filters.dateFrom) : undefined,
|
|
lte: filters.dateTo ? new Date(filters.dateTo) : undefined,
|
|
}
|
|
}
|
|
|
|
if (filters.categoryCode) {
|
|
where.categoryCode = filters.categoryCode
|
|
}
|
|
|
|
if (filters.projectCode) {
|
|
where.projectCode = filters.projectCode
|
|
}
|
|
|
|
if (filters.type) {
|
|
where.type = filters.type
|
|
}
|
|
|
|
const [transactions, categories] = await Promise.all([
|
|
prisma.transaction.findMany({
|
|
where,
|
|
include: {
|
|
category: true,
|
|
},
|
|
orderBy: { issuedAt: "asc" },
|
|
}),
|
|
prisma.category.findMany({
|
|
where: { userId },
|
|
orderBy: { name: "asc" },
|
|
}),
|
|
])
|
|
|
|
if (transactions.length === 0) {
|
|
return []
|
|
}
|
|
|
|
// Determine if we should group by day or month
|
|
const dateFrom = filters.dateFrom ? new Date(filters.dateFrom) : new Date(transactions[0].issuedAt!)
|
|
const dateTo = filters.dateTo ? new Date(filters.dateTo) : new Date(transactions[transactions.length - 1].issuedAt!)
|
|
const daysDiff = Math.ceil((dateTo.getTime() - dateFrom.getTime()) / (1000 * 60 * 60 * 24))
|
|
const groupByDay = daysDiff <= 50
|
|
|
|
// Create category lookup
|
|
const categoryLookup = new Map(categories.map((cat) => [cat.code, cat]))
|
|
|
|
// Group transactions by time period
|
|
const grouped = transactions.reduce(
|
|
(acc, transaction) => {
|
|
if (!transaction.issuedAt) return acc
|
|
|
|
const date = new Date(transaction.issuedAt)
|
|
const period = groupByDay
|
|
? date.toISOString().split("T")[0] // YYYY-MM-DD
|
|
: `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}` // YYYY-MM
|
|
|
|
if (!acc[period]) {
|
|
acc[period] = {
|
|
period,
|
|
income: 0,
|
|
expenses: 0,
|
|
date,
|
|
categories: new Map<string, CategoryBreakdown>(),
|
|
totalTransactions: 0,
|
|
}
|
|
}
|
|
|
|
// Get amount in default currency
|
|
const amount =
|
|
transaction.convertedCurrencyCode?.toUpperCase() === defaultCurrency.toUpperCase()
|
|
? transaction.convertedTotal || 0
|
|
: transaction.currencyCode?.toUpperCase() === defaultCurrency.toUpperCase()
|
|
? transaction.total || 0
|
|
: 0 // Skip transactions not in default currency for simplicity
|
|
|
|
const categoryCode = transaction.categoryCode || "other"
|
|
const category = categoryLookup.get(categoryCode) || {
|
|
code: "other",
|
|
name: "Other",
|
|
color: "#6b7280",
|
|
}
|
|
|
|
// Initialize category if not exists
|
|
if (!acc[period].categories.has(categoryCode)) {
|
|
acc[period].categories.set(categoryCode, {
|
|
code: category.code,
|
|
name: category.name,
|
|
color: category.color || "#6b7280",
|
|
income: 0,
|
|
expenses: 0,
|
|
transactionCount: 0,
|
|
})
|
|
}
|
|
|
|
const categoryData = acc[period].categories.get(categoryCode)!
|
|
categoryData.transactionCount++
|
|
acc[period].totalTransactions++
|
|
|
|
if (transaction.type === "income") {
|
|
acc[period].income += amount
|
|
categoryData.income += amount
|
|
} else if (transaction.type === "expense") {
|
|
acc[period].expenses += amount
|
|
categoryData.expenses += amount
|
|
}
|
|
|
|
return acc
|
|
},
|
|
{} as Record<
|
|
string,
|
|
{
|
|
period: string
|
|
income: number
|
|
expenses: number
|
|
date: Date
|
|
categories: Map<string, CategoryBreakdown>
|
|
totalTransactions: number
|
|
}
|
|
>
|
|
)
|
|
|
|
return Object.values(grouped)
|
|
.map((item) => ({
|
|
...item,
|
|
categories: Array.from(item.categories.values()).filter((cat) => cat.income > 0 || cat.expenses > 0),
|
|
}))
|
|
.sort((a, b) => a.date.getTime() - b.date.getTime())
|
|
}
|
|
)
|