import { and, desc, eq } from "drizzle-orm"; import { cards, categories, financialAccounts, transactions, } from "@/db/schema"; import type { DashboardBillsSnapshot } from "@/features/dashboard/bills/bills-queries"; import type { InstallmentExpensesData } from "@/features/dashboard/expenses/installment-expenses-queries"; import type { RecurringExpensesData } from "@/features/dashboard/expenses/recurring-expenses-queries"; import type { TopExpense, TopExpensesData, } from "@/features/dashboard/expenses/top-expenses-queries"; import type { PaymentConditionsData } from "@/features/dashboard/payments/payment-conditions-queries"; import type { PaymentMethodsData } from "@/features/dashboard/payments/payment-methods-queries"; import type { PaymentStatusData } from "@/features/dashboard/payments/payment-status-queries"; import type { PurchasesByCategoryData } from "@/features/dashboard/categories/purchases-by-category-queries"; import type { TopEstablishmentsData } from "@/features/dashboard/top-establishments-queries"; import { excludeTransactionsFromExcludedAccounts } from "@/features/dashboard/transaction-filters"; import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX, INITIAL_BALANCE_NOTE, } from "@/shared/lib/accounts/constants"; import { db } from "@/shared/lib/db"; import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id"; import { compareDateOnly, getBusinessDateString, isDateOnlyPast, } from "@/shared/utils/date"; import { safeToNumber as toNumber } from "@/shared/utils/number"; const PAYMENT_METHOD_BOLETO = "Boleto"; const PAYMENT_METHOD_CARD = "Cartão de Crédito"; const TRANSACTION_TYPE_EXPENSE = "Despesa"; const TRANSACTION_TYPE_INCOME = "Receita"; const CONDITION_RECURRING = "Recorrente"; const CONDITION_INSTALLMENT = "Parcelado"; type CurrentPeriodTransactionRow = { id: string; name: string; amount: string | number | null; period: string; purchaseDate: Date; paymentMethod: string; condition: string; currentInstallment: number | null; installmentCount: number | null; recurrenceCount: number | null; dueDate: Date | null; boletoPaymentDate: Date | null; isSettled: boolean | null; transactionType: string; note: string | null; isAnticipated: boolean; categoryId: string | null; categoryName: string | null; categoryType: string | null; cardLogo: string | null; accountLogo: string | null; accountExcludeInitialBalanceFromIncome: boolean | null; }; type CategoryOption = PurchasesByCategoryData["categories"][number]; type CategoryTransaction = PurchasesByCategoryData["transactionsByCategory"][string][number]; type DashboardCurrentPeriodOverview = { billsSnapshot: DashboardBillsSnapshot; paymentStatusData: PaymentStatusData; paymentConditionsData: PaymentConditionsData; paymentMethodsData: PaymentMethodsData; recurringExpensesData: RecurringExpensesData; installmentExpensesData: InstallmentExpensesData; topEstablishmentsData: TopEstablishmentsData; topExpensesAll: TopExpensesData; topExpensesCardOnly: TopExpensesData; purchasesByCategoryData: PurchasesByCategoryData; }; const emptyOverview = (): DashboardCurrentPeriodOverview => ({ billsSnapshot: { bills: [], totalPendingAmount: 0, pendingCount: 0, }, paymentStatusData: { income: { total: 0, confirmed: 0, pending: 0 }, expenses: { total: 0, confirmed: 0, pending: 0 }, }, paymentConditionsData: { conditions: [] }, paymentMethodsData: { methods: [] }, recurringExpensesData: { expenses: [] }, installmentExpensesData: { expenses: [] }, topEstablishmentsData: { establishments: [] }, topExpensesAll: { expenses: [] }, topExpensesCardOnly: { expenses: [] }, purchasesByCategoryData: { categories: [], transactionsByCategory: {}, }, }); const normalizeNote = (note: string | null | undefined) => note?.trim() ?? ""; const isAutoInvoiceNote = (note: string | null | undefined) => normalizeNote(note) .toLowerCase() .startsWith(ACCOUNT_AUTO_INVOICE_NOTE_PREFIX.toLowerCase()); const isInitialBalanceNote = (note: string | null | undefined) => normalizeNote(note) === INITIAL_BALANCE_NOTE; const shouldIncludeWithoutAutoInvoice = (note: string | null | undefined) => !isAutoInvoiceNote(note); const shouldIncludeWithoutAutoGenerated = (note: string | null | undefined) => !isInitialBalanceNote(note) && !isAutoInvoiceNote(note); const shouldIncludeInPaymentStatus = (row: CurrentPeriodTransactionRow) => { if (!shouldIncludeWithoutAutoInvoice(row.note)) { return false; } if ( isInitialBalanceNote(row.note) && row.accountExcludeInitialBalanceFromIncome === true ) { return false; } return true; }; const shouldIncludeNamedItem = (name: string) => { const normalized = name.trim().toLowerCase(); if (normalized === "saldo inicial") { return false; } if (normalized.includes("fatura")) { return false; } return true; }; const compareDateOnlyAscWithNullsLast = ( left: string | null, right: string | null, ) => { if (!left && !right) return 0; if (!left) return 1; if (!right) return -1; return compareDateOnly(left, right); }; const compareDateOnlyDescWithNullsLast = ( left: string | null, right: string | null, ) => { if (!left && !right) return 0; if (!left) return 1; if (!right) return -1; return compareDateOnly(right, left); }; const buildBillsSnapshot = ( rows: CurrentPeriodTransactionRow[], ): DashboardBillsSnapshot => { const today = getBusinessDateString(); const bills = rows .filter((row) => row.paymentMethod === PAYMENT_METHOD_BOLETO) .map((row) => ({ id: row.id, name: row.name, amount: Math.abs(toNumber(row.amount)), dueDate: row.dueDate ? row.dueDate.toISOString().slice(0, 10) : null, boletoPaymentDate: row.boletoPaymentDate ? row.boletoPaymentDate.toISOString().slice(0, 10) : null, isSettled: Boolean(row.isSettled), })) .sort((a, b) => { if (a.isSettled !== b.isSettled) { return a.isSettled ? 1 : -1; } if (!a.isSettled && !b.isSettled) { const aIsOverdue = a.dueDate ? isDateOnlyPast(a.dueDate, today) : false; const bIsOverdue = b.dueDate ? isDateOnlyPast(b.dueDate, today) : false; if (aIsOverdue !== bIsOverdue) { return aIsOverdue ? -1 : 1; } const dueDateDiff = compareDateOnlyAscWithNullsLast( a.dueDate, b.dueDate, ); if (dueDateDiff !== 0) { return dueDateDiff; } const amountDiff = b.amount - a.amount; if (amountDiff !== 0) { return amountDiff; } } if (a.isSettled && b.isSettled) { const paidAtDiff = compareDateOnlyDescWithNullsLast( a.boletoPaymentDate, b.boletoPaymentDate, ); if (paidAtDiff !== 0) { return paidAtDiff; } const amountDiff = b.amount - a.amount; if (amountDiff !== 0) { return amountDiff; } } return a.name.localeCompare(b.name, "pt-BR"); }); const pendingBills = bills.filter((bill) => !bill.isSettled); return { bills, totalPendingAmount: pendingBills.reduce( (total, bill) => total + bill.amount, 0, ), pendingCount: pendingBills.length, }; }; const buildPaymentStatusData = ( rows: CurrentPeriodTransactionRow[], ): PaymentStatusData => { const result: PaymentStatusData = { income: { total: 0, confirmed: 0, pending: 0 }, expenses: { total: 0, confirmed: 0, pending: 0 }, }; for (const row of rows) { if ( !shouldIncludeInPaymentStatus(row) || (row.transactionType !== TRANSACTION_TYPE_INCOME && row.transactionType !== TRANSACTION_TYPE_EXPENSE) ) { continue; } const amount = toNumber(row.amount); const target = row.transactionType === TRANSACTION_TYPE_INCOME ? result.income : result.expenses; if (row.isSettled === true) { target.confirmed += amount; } else { target.pending += amount; } } result.income.total = result.income.confirmed + result.income.pending; result.expenses.total = result.expenses.confirmed + result.expenses.pending; return result; }; const buildExpenseBreakdown = ( rows: CurrentPeriodTransactionRow[], field: "condition" | "paymentMethod", ) => { const groups = new Map< string, { amount: number; transactions: number; } >(); for (const row of rows) { if ( row.transactionType !== TRANSACTION_TYPE_EXPENSE || !shouldIncludeWithoutAutoGenerated(row.note) ) { continue; } const key = row[field]; const current = groups.get(key) ?? { amount: 0, transactions: 0 }; current.amount += Math.abs(toNumber(row.amount)); current.transactions += 1; groups.set(key, current); } const entries = Array.from(groups.entries()).map(([key, value]) => ({ key, amount: value.amount, transactions: value.transactions, })); const overallTotal = entries.reduce( (total, entry) => total + entry.amount, 0, ); return entries .map((entry) => ({ key: entry.key, amount: entry.amount, transactions: entry.transactions, percentage: overallTotal > 0 ? Number(((entry.amount / overallTotal) * 100).toFixed(2)) : 0, })) .sort((a, b) => b.amount - a.amount); }; const buildPaymentConditionsData = ( rows: CurrentPeriodTransactionRow[], ): PaymentConditionsData => ({ conditions: buildExpenseBreakdown(rows, "condition").map((entry) => ({ condition: entry.key, amount: entry.amount, transactions: entry.transactions, percentage: entry.percentage, })), }); const buildPaymentMethodsData = ( rows: CurrentPeriodTransactionRow[], ): PaymentMethodsData => ({ methods: buildExpenseBreakdown(rows, "paymentMethod").map((entry) => ({ paymentMethod: entry.key, amount: entry.amount, transactions: entry.transactions, percentage: entry.percentage, })), }); const buildRecurringExpensesData = ( rows: CurrentPeriodTransactionRow[], ): RecurringExpensesData => ({ expenses: rows .filter( (row) => row.transactionType === TRANSACTION_TYPE_EXPENSE && row.condition === CONDITION_RECURRING && shouldIncludeWithoutAutoGenerated(row.note), ) .map((row) => ({ id: row.id, name: row.name, amount: Math.abs(toNumber(row.amount)), paymentMethod: row.paymentMethod, recurrenceCount: row.recurrenceCount, })), }); const buildInstallmentExpensesData = ( rows: CurrentPeriodTransactionRow[], ): InstallmentExpensesData => ({ expenses: rows .filter( (row) => row.transactionType === TRANSACTION_TYPE_EXPENSE && row.condition === CONDITION_INSTALLMENT && row.isAnticipated === false && shouldIncludeWithoutAutoGenerated(row.note), ) .map((row) => ({ id: row.id, name: row.name, amount: Math.abs(toNumber(row.amount)), paymentMethod: row.paymentMethod, currentInstallment: row.currentInstallment, installmentCount: row.installmentCount, dueDate: row.dueDate, purchaseDate: row.purchaseDate, period: row.period, })) .sort((a, b) => { const remainingA = a.installmentCount && a.currentInstallment ? a.installmentCount - a.currentInstallment : 0; const remainingB = b.installmentCount && b.currentInstallment ? b.installmentCount - b.currentInstallment : 0; return remainingA - remainingB; }), }); const mapTopExpense = (row: CurrentPeriodTransactionRow): TopExpense => ({ id: row.id, name: row.name, amount: Math.abs(toNumber(row.amount)), purchaseDate: row.purchaseDate, paymentMethod: row.paymentMethod, logo: row.cardLogo ?? row.accountLogo ?? null, }); const buildTopExpensesData = ( rows: CurrentPeriodTransactionRow[], cardOnly: boolean, ): TopExpensesData => ({ expenses: rows .filter( (row) => row.transactionType === TRANSACTION_TYPE_EXPENSE && shouldIncludeWithoutAutoGenerated(row.note) && (!cardOnly || row.paymentMethod === PAYMENT_METHOD_CARD), ) .sort((a, b) => toNumber(a.amount) - toNumber(b.amount)) .slice(0, 10) .map(mapTopExpense), }); const buildTopEstablishmentsData = ( rows: CurrentPeriodTransactionRow[], ): TopEstablishmentsData => { const groups = new Map< string, { amount: number; occurrences: number; logo: string | null; } >(); for (const row of rows) { if ( row.transactionType !== TRANSACTION_TYPE_EXPENSE || !shouldIncludeWithoutAutoGenerated(row.note) ) { continue; } const current = groups.get(row.name) ?? { amount: 0, occurrences: 0, logo: row.cardLogo ?? row.accountLogo ?? null, }; current.amount += toNumber(row.amount); current.occurrences += 1; current.logo = current.logo ?? row.cardLogo ?? row.accountLogo ?? null; groups.set(row.name, current); } const topRows = Array.from(groups.entries()) .map(([name, value]) => ({ id: name, name, amount: Math.abs(value.amount), occurrences: value.occurrences, logo: value.logo, })) .sort((a, b) => { if (a.occurrences !== b.occurrences) { return b.occurrences - a.occurrences; } return b.amount - a.amount; }) .slice(0, 10); return { establishments: topRows.filter((row) => shouldIncludeNamedItem(row.name)), }; }; const buildPurchasesByCategoryData = ( rows: CurrentPeriodTransactionRow[], ): PurchasesByCategoryData => { const categoriesMap = new Map(); const transactionsByCategory: Record = {}; for (const row of rows) { if ( !row.categoryId || !row.categoryName || !row.categoryType || !["despesa", "receita"].includes(row.categoryType) || !shouldIncludeWithoutAutoGenerated(row.note) || !shouldIncludeNamedItem(row.name) ) { continue; } if (!categoriesMap.has(row.categoryId)) { categoriesMap.set(row.categoryId, { id: row.categoryId, name: row.categoryName, type: row.categoryType, }); } const transactionList = transactionsByCategory[row.categoryId] ?? []; if (transactionList.length < 10) { transactionList.push({ id: row.id, name: row.name, amount: Math.abs(toNumber(row.amount)), purchaseDate: row.purchaseDate, logo: row.cardLogo ?? row.accountLogo ?? null, }); transactionsByCategory[row.categoryId] = transactionList; } } return { categories: Array.from(categoriesMap.values()).sort((a, b) => { if (a.type !== b.type) { return a.type === "receita" ? -1 : 1; } return a.name.localeCompare(b.name, "pt-BR"); }), transactionsByCategory, }; }; export async function fetchDashboardCurrentPeriodOverview( userId: string, period: string, ): Promise { const adminPayerId = await getAdminPayerId(userId); if (!adminPayerId) { return emptyOverview(); } const rows = (await db .select({ id: transactions.id, name: transactions.name, amount: transactions.amount, period: transactions.period, purchaseDate: transactions.purchaseDate, paymentMethod: transactions.paymentMethod, condition: transactions.condition, currentInstallment: transactions.currentInstallment, installmentCount: transactions.installmentCount, recurrenceCount: transactions.recurrenceCount, dueDate: transactions.dueDate, boletoPaymentDate: transactions.boletoPaymentDate, isSettled: transactions.isSettled, transactionType: transactions.transactionType, note: transactions.note, isAnticipated: transactions.isAnticipated, categoryId: transactions.categoryId, categoryName: categories.name, categoryType: categories.type, cardLogo: cards.logo, accountLogo: financialAccounts.logo, accountExcludeInitialBalanceFromIncome: financialAccounts.excludeInitialBalanceFromIncome, }) .from(transactions) .leftJoin(cards, eq(transactions.cardId, cards.id)) .leftJoin( financialAccounts, eq(transactions.accountId, financialAccounts.id), ) .leftJoin(categories, eq(transactions.categoryId, categories.id)) .where( and( eq(transactions.userId, userId), eq(transactions.period, period), eq(transactions.payerId, adminPayerId), excludeTransactionsFromExcludedAccounts(), ), ) .orderBy( desc(transactions.purchaseDate), desc(transactions.createdAt), )) as CurrentPeriodTransactionRow[]; return { billsSnapshot: buildBillsSnapshot(rows), paymentStatusData: buildPaymentStatusData(rows), paymentConditionsData: buildPaymentConditionsData(rows), paymentMethodsData: buildPaymentMethodsData(rows), recurringExpensesData: buildRecurringExpensesData(rows), installmentExpensesData: buildInstallmentExpensesData(rows), topEstablishmentsData: buildTopEstablishmentsData(rows), topExpensesAll: buildTopExpensesData(rows, false), topExpensesCardOnly: buildTopExpensesData(rows, true), purchasesByCategoryData: buildPurchasesByCategoryData(rows), }; }