Files
openmonetis/src/features/dashboard/category-overview-queries.ts
2026-04-03 18:10:43 +00:00

290 lines
7.3 KiB
TypeScript

import { and, eq, inArray, or, sql } from "drizzle-orm";
import {
budgets,
categories,
financialAccounts,
transactions,
} from "@/db/schema";
import {
buildCategoryBreakdownData,
type DashboardCategoryBreakdownData,
} from "@/features/dashboard/categories/category-breakdown";
import type { ExpensesByCategoryData } from "@/features/dashboard/categories/expenses-by-category-queries";
import type { IncomeByCategoryData } from "@/features/dashboard/categories/income-by-category-queries";
import type {
GoalProgressCategory,
GoalProgressItem,
GoalsProgressData,
} from "@/features/dashboard/goals-progress-queries";
import {
buildDashboardAdminFilters,
excludeAutoInvoiceEntries,
excludeInitialBalanceWhenConfigured,
excludeTransactionsFromExcludedAccounts,
} from "@/features/dashboard/transaction-filters";
import { db } from "@/shared/lib/db";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
import { safeToNumber as toNumber } from "@/shared/utils/number";
import { getPreviousPeriod } from "@/shared/utils/period";
const BUDGET_CRITICAL_THRESHOLD = 80;
type CategorySnapshotRow = {
categoryId: string;
categoryName: string;
categoryIcon: string | null;
categoryType: string | null;
period: string | null;
condition: string;
total: number;
absoluteTotal: number;
};
type BudgetSnapshotRow = {
budgetId: string;
categoryId: string | null;
categoryName: string;
categoryIcon: string | null;
period: string;
createdAt: Date;
amount: string | number | null;
};
export type DashboardCategoryOverview = {
goalsProgressData: GoalsProgressData;
incomeByCategoryData: IncomeByCategoryData;
expensesByCategoryData: ExpensesByCategoryData;
};
const resolveStatus = (usedPercentage: number): GoalProgressItem["status"] => {
if (usedPercentage >= 100) {
return "exceeded";
}
if (usedPercentage >= BUDGET_CRITICAL_THRESHOLD) {
return "critical";
}
return "on-track";
};
const emptyOverview = (): DashboardCategoryOverview => ({
goalsProgressData: {
items: [],
categories: [],
totalBudgets: 0,
exceededCount: 0,
criticalCount: 0,
},
incomeByCategoryData: {
categories: [],
currentTotal: 0,
previousTotal: 0,
},
expensesByCategoryData: {
categories: [],
currentTotal: 0,
previousTotal: 0,
},
});
const aggregateCategoryRows = (
rows: CategorySnapshotRow[],
categoryType: "receita" | "despesa",
) => {
const grouped = new Map<
string,
{
categoryId: string;
categoryName: string;
categoryIcon: string | null;
period: string | null;
total: number;
}
>();
for (const row of rows) {
if (row.categoryType !== categoryType) {
continue;
}
const key = `${row.categoryId}:${row.period ?? "sem-periodo"}`;
const current = grouped.get(key) ?? {
categoryId: row.categoryId,
categoryName: row.categoryName,
categoryIcon: row.categoryIcon,
period: row.period,
total: 0,
};
current.total += toNumber(row.total);
grouped.set(key, current);
}
return Array.from(grouped.values());
};
export async function fetchDashboardCategoryOverview(
userId: string,
period: string,
): Promise<DashboardCategoryOverview> {
const adminPayerId = await getAdminPayerId(userId);
if (!adminPayerId) {
return emptyOverview();
}
const previousPeriod = getPreviousPeriod(period);
const [transactionRows, budgetRows, categoryRows] = await Promise.all([
db
.select({
categoryId: categories.id,
categoryName: categories.name,
categoryIcon: categories.icon,
categoryType: categories.type,
period: transactions.period,
condition: transactions.condition,
total: sql<number>`coalesce(sum(${transactions.amount}), 0)`,
absoluteTotal: sql<number>`coalesce(sum(abs(${transactions.amount})), 0)`,
})
.from(transactions)
.innerJoin(categories, eq(transactions.categoryId, categories.id))
.leftJoin(
financialAccounts,
eq(transactions.accountId, financialAccounts.id),
)
.where(
and(
...buildDashboardAdminFilters({ userId, adminPayerId }),
inArray(transactions.period, [period, previousPeriod]),
excludeTransactionsFromExcludedAccounts(),
or(
and(
eq(transactions.transactionType, "Despesa"),
eq(categories.type, "despesa"),
excludeAutoInvoiceEntries(),
),
and(
eq(transactions.transactionType, "Receita"),
eq(categories.type, "receita"),
excludeAutoInvoiceEntries(),
excludeInitialBalanceWhenConfigured(),
),
),
),
)
.groupBy(
categories.id,
categories.name,
categories.icon,
categories.type,
transactions.period,
transactions.condition,
),
db
.select({
budgetId: budgets.id,
categoryId: budgets.categoryId,
categoryName: categories.name,
categoryIcon: categories.icon,
period: budgets.period,
createdAt: budgets.createdAt,
amount: budgets.amount,
})
.from(budgets)
.innerJoin(categories, eq(budgets.categoryId, categories.id))
.where(and(eq(budgets.userId, userId), eq(budgets.period, period))),
db.query.categories.findMany({
where: and(eq(categories.userId, userId), eq(categories.type, "despesa")),
orderBy: (category, { asc }) => [asc(category.name)],
}),
]);
const snapshotRows = transactionRows as CategorySnapshotRow[];
const incomeRows = aggregateCategoryRows(snapshotRows, "receita");
const expenseRows = aggregateCategoryRows(snapshotRows, "despesa");
const budgetAmountRows = (budgetRows as BudgetSnapshotRow[]).map((row) => ({
categoryId: row.categoryId,
amount: row.amount,
}));
const incomeByCategoryData: DashboardCategoryBreakdownData =
buildCategoryBreakdownData({
rows: incomeRows,
budgetRows: budgetAmountRows,
period,
});
const expensesByCategoryData: DashboardCategoryBreakdownData =
buildCategoryBreakdownData({
rows: expenseRows,
budgetRows: budgetAmountRows,
period,
});
const currentExpenseMap = new Map<string, number>();
for (const row of snapshotRows) {
if (
row.categoryType === "despesa" &&
row.period === period &&
row.condition !== "cancelado"
) {
currentExpenseMap.set(
row.categoryId,
(currentExpenseMap.get(row.categoryId) ?? 0) +
toNumber(row.absoluteTotal),
);
}
}
const goalsCategories: GoalProgressCategory[] = categoryRows.map(
(category) => ({
id: category.id,
name: category.name,
icon: category.icon,
}),
);
const goalItems: GoalProgressItem[] = (budgetRows as BudgetSnapshotRow[])
.map((row) => {
const budgetAmount = toNumber(row.amount);
const spentAmount = row.categoryId
? (currentExpenseMap.get(row.categoryId) ?? 0)
: 0;
const usedPercentage =
budgetAmount > 0 ? (spentAmount / budgetAmount) * 100 : 0;
return {
id: row.budgetId,
categoryId: row.categoryId,
categoryName: row.categoryName,
categoryIcon: row.categoryIcon,
period: row.period,
createdAt: row.createdAt.toISOString(),
budgetAmount,
spentAmount,
usedPercentage,
status: resolveStatus(usedPercentage),
};
})
.sort((a, b) => b.usedPercentage - a.usedPercentage);
const exceededCount = goalItems.filter(
(item) => item.status === "exceeded",
).length;
const criticalCount = goalItems.filter(
(item) => item.status === "critical",
).length;
return {
goalsProgressData: {
items: goalItems,
categories: goalsCategories,
totalBudgets: goalItems.length,
exceededCount,
criticalCount,
},
incomeByCategoryData,
expensesByCategoryData,
};
}