mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-06-10 07:16:01 +00:00
refactor(core): move app para src e padroniza estrutura
This commit is contained in:
181
src/features/dashboard/expenses/installment-analysis-queries.ts
Normal file
181
src/features/dashboard/expenses/installment-analysis-queries.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import { and, eq, isNotNull, isNull, or, sql } from "drizzle-orm";
|
||||
import { cartoes, lancamentos, pagadores } from "@/db/schema";
|
||||
import {
|
||||
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
|
||||
INITIAL_BALANCE_NOTE,
|
||||
} from "@/shared/lib/accounts/constants";
|
||||
import { db } from "@/shared/lib/db";
|
||||
import { PAGADOR_ROLE_ADMIN } from "@/shared/lib/payers/constants";
|
||||
import {
|
||||
buildDateOnlyStringFromPeriodDay,
|
||||
parseLocalDateString,
|
||||
} from "@/shared/utils/date";
|
||||
import { safeToNumber as toNumber } from "@/shared/utils/number";
|
||||
|
||||
// Calcula a data de vencimento baseada no período e dia de vencimento do cartão
|
||||
function calculateDueDate(period: string, dueDay: string | null): Date | null {
|
||||
if (!dueDay) return null;
|
||||
|
||||
try {
|
||||
const dueDateString = buildDateOnlyStringFromPeriodDay(period, dueDay);
|
||||
if (!dueDateString) return null;
|
||||
|
||||
const dueDate = parseLocalDateString(dueDateString);
|
||||
if (Number.isNaN(dueDate.getTime())) return null;
|
||||
|
||||
// Meio-dia evita drift visual em serialização/locales diferentes.
|
||||
dueDate.setHours(12, 0, 0, 0);
|
||||
return dueDate;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export type InstallmentDetail = {
|
||||
id: string;
|
||||
currentInstallment: number;
|
||||
amount: number;
|
||||
dueDate: Date | null;
|
||||
period: string;
|
||||
isAnticipated: boolean;
|
||||
purchaseDate: Date;
|
||||
isSettled: boolean;
|
||||
};
|
||||
|
||||
export type InstallmentGroup = {
|
||||
seriesId: string;
|
||||
name: string;
|
||||
paymentMethod: string;
|
||||
cartaoId: string | null;
|
||||
cartaoName: string | null;
|
||||
cartaoDueDay: string | null;
|
||||
cartaoLogo: string | null;
|
||||
totalInstallments: number;
|
||||
paidInstallments: number;
|
||||
pendingInstallments: InstallmentDetail[];
|
||||
totalPendingAmount: number;
|
||||
firstPurchaseDate: Date;
|
||||
};
|
||||
|
||||
export type InstallmentAnalysisData = {
|
||||
installmentGroups: InstallmentGroup[];
|
||||
totalPendingInstallments: number;
|
||||
};
|
||||
|
||||
export async function fetchInstallmentAnalysis(
|
||||
userId: string,
|
||||
): Promise<InstallmentAnalysisData> {
|
||||
// 1. Buscar todos os lançamentos parcelados não antecipados do pagador admin
|
||||
const installmentRows = await db
|
||||
.select({
|
||||
id: lancamentos.id,
|
||||
seriesId: lancamentos.seriesId,
|
||||
name: lancamentos.name,
|
||||
amount: lancamentos.amount,
|
||||
paymentMethod: lancamentos.paymentMethod,
|
||||
currentInstallment: lancamentos.currentInstallment,
|
||||
installmentCount: lancamentos.installmentCount,
|
||||
dueDate: lancamentos.dueDate,
|
||||
period: lancamentos.period,
|
||||
isAnticipated: lancamentos.isAnticipated,
|
||||
isSettled: lancamentos.isSettled,
|
||||
purchaseDate: lancamentos.purchaseDate,
|
||||
cartaoId: lancamentos.cartaoId,
|
||||
cartaoName: cartoes.name,
|
||||
cartaoDueDay: cartoes.dueDay,
|
||||
cartaoLogo: cartoes.logo,
|
||||
})
|
||||
.from(lancamentos)
|
||||
.leftJoin(cartoes, eq(lancamentos.cartaoId, cartoes.id))
|
||||
.leftJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.transactionType, "Despesa"),
|
||||
eq(lancamentos.condition, "Parcelado"),
|
||||
eq(lancamentos.isAnticipated, false),
|
||||
isNotNull(lancamentos.seriesId),
|
||||
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
|
||||
or(
|
||||
isNull(lancamentos.note),
|
||||
and(
|
||||
sql`${lancamentos.note} != ${INITIAL_BALANCE_NOTE}`,
|
||||
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.orderBy(lancamentos.purchaseDate, lancamentos.currentInstallment);
|
||||
|
||||
// Agrupar por seriesId
|
||||
const seriesMap = new Map<string, InstallmentGroup>();
|
||||
|
||||
for (const row of installmentRows) {
|
||||
if (!row.seriesId) continue;
|
||||
|
||||
const amount = Math.abs(toNumber(row.amount));
|
||||
|
||||
// Calcular vencimento correto baseado no período e dia de vencimento do cartão
|
||||
const calculatedDueDate = row.cartaoDueDay
|
||||
? calculateDueDate(row.period, row.cartaoDueDay)
|
||||
: row.dueDate;
|
||||
|
||||
const installmentDetail: InstallmentDetail = {
|
||||
id: row.id,
|
||||
currentInstallment: row.currentInstallment ?? 1,
|
||||
amount,
|
||||
dueDate: calculatedDueDate,
|
||||
period: row.period,
|
||||
isAnticipated: row.isAnticipated ?? false,
|
||||
purchaseDate: row.purchaseDate,
|
||||
isSettled: row.isSettled ?? false,
|
||||
};
|
||||
|
||||
if (seriesMap.has(row.seriesId)) {
|
||||
const group = seriesMap.get(row.seriesId);
|
||||
group?.pendingInstallments.push(installmentDetail);
|
||||
if (group) group.totalPendingAmount += amount;
|
||||
} else {
|
||||
seriesMap.set(row.seriesId, {
|
||||
seriesId: row.seriesId,
|
||||
name: row.name,
|
||||
paymentMethod: row.paymentMethod,
|
||||
cartaoId: row.cartaoId,
|
||||
cartaoName: row.cartaoName,
|
||||
cartaoDueDay: row.cartaoDueDay,
|
||||
cartaoLogo: row.cartaoLogo,
|
||||
totalInstallments: row.installmentCount ?? 0,
|
||||
paidInstallments: 0,
|
||||
pendingInstallments: [installmentDetail],
|
||||
totalPendingAmount: amount,
|
||||
firstPurchaseDate: row.purchaseDate,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Calcular quantas parcelas já foram pagas para cada grupo
|
||||
const installmentGroups = Array.from(seriesMap.values())
|
||||
.map((group) => {
|
||||
// Contar quantas parcelas estão marcadas como pagas (settled)
|
||||
const paidCount = group.pendingInstallments.filter(
|
||||
(i) => i.isSettled,
|
||||
).length;
|
||||
group.paidInstallments = paidCount;
|
||||
return group;
|
||||
})
|
||||
// Filtrar apenas séries que têm pelo menos uma parcela em aberto (não paga)
|
||||
.filter((group) => {
|
||||
const hasUnpaidInstallments = group.pendingInstallments.some(
|
||||
(i) => !i.isSettled,
|
||||
);
|
||||
return hasUnpaidInstallments;
|
||||
});
|
||||
|
||||
// Calcular totais
|
||||
const totalPendingInstallments = installmentGroups.reduce(
|
||||
(sum, group) => sum + group.totalPendingAmount,
|
||||
0,
|
||||
);
|
||||
|
||||
return { installmentGroups, totalPendingInstallments };
|
||||
}
|
||||
100
src/features/dashboard/expenses/installment-expenses-queries.ts
Normal file
100
src/features/dashboard/expenses/installment-expenses-queries.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { and, desc, eq, isNull, or, sql } from "drizzle-orm";
|
||||
import { lancamentos } from "@/db/schema";
|
||||
import {
|
||||
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
|
||||
INITIAL_BALANCE_NOTE,
|
||||
} from "@/shared/lib/accounts/constants";
|
||||
import { db } from "@/shared/lib/db";
|
||||
import { getAdminPagadorId } from "@/shared/lib/payers/get-admin-id";
|
||||
import { safeToNumber as toNumber } from "@/shared/utils/number";
|
||||
|
||||
export type InstallmentExpense = {
|
||||
id: string;
|
||||
name: string;
|
||||
amount: number;
|
||||
paymentMethod: string;
|
||||
currentInstallment: number | null;
|
||||
installmentCount: number | null;
|
||||
dueDate: Date | null;
|
||||
purchaseDate: Date;
|
||||
period: string;
|
||||
};
|
||||
|
||||
export type InstallmentExpensesData = {
|
||||
expenses: InstallmentExpense[];
|
||||
};
|
||||
|
||||
export async function fetchInstallmentExpenses(
|
||||
userId: string,
|
||||
period: string,
|
||||
): Promise<InstallmentExpensesData> {
|
||||
const adminPagadorId = await getAdminPagadorId(userId);
|
||||
if (!adminPagadorId) {
|
||||
return { expenses: [] };
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
id: lancamentos.id,
|
||||
name: lancamentos.name,
|
||||
amount: lancamentos.amount,
|
||||
paymentMethod: lancamentos.paymentMethod,
|
||||
currentInstallment: lancamentos.currentInstallment,
|
||||
installmentCount: lancamentos.installmentCount,
|
||||
dueDate: lancamentos.dueDate,
|
||||
purchaseDate: lancamentos.purchaseDate,
|
||||
period: lancamentos.period,
|
||||
})
|
||||
.from(lancamentos)
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.period, period),
|
||||
eq(lancamentos.transactionType, "Despesa"),
|
||||
eq(lancamentos.condition, "Parcelado"),
|
||||
eq(lancamentos.isAnticipated, false),
|
||||
eq(lancamentos.pagadorId, adminPagadorId),
|
||||
or(
|
||||
isNull(lancamentos.note),
|
||||
and(
|
||||
sql`${lancamentos.note} != ${INITIAL_BALANCE_NOTE}`,
|
||||
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(lancamentos.purchaseDate), desc(lancamentos.createdAt));
|
||||
|
||||
type InstallmentExpenseRow = (typeof rows)[number];
|
||||
|
||||
const expenses = rows
|
||||
.map(
|
||||
(row: InstallmentExpenseRow): InstallmentExpense => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
amount: Math.abs(toNumber(row.amount)),
|
||||
paymentMethod: row.paymentMethod,
|
||||
currentInstallment: row.currentInstallment,
|
||||
installmentCount: row.installmentCount,
|
||||
dueDate: row.dueDate ?? null,
|
||||
purchaseDate: row.purchaseDate,
|
||||
period: row.period,
|
||||
}),
|
||||
)
|
||||
.sort((a: InstallmentExpense, b: InstallmentExpense) => {
|
||||
// Calcula parcelas restantes para cada item
|
||||
const remainingA =
|
||||
a.installmentCount && a.currentInstallment
|
||||
? a.installmentCount - a.currentInstallment
|
||||
: 0;
|
||||
const remainingB =
|
||||
b.installmentCount && b.currentInstallment
|
||||
? b.installmentCount - b.currentInstallment
|
||||
: 0;
|
||||
|
||||
// Ordena do menor número de parcelas restantes para o maior
|
||||
return remainingA - remainingB;
|
||||
});
|
||||
|
||||
return { expenses };
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { and, desc, eq, isNull, or, sql } from "drizzle-orm";
|
||||
import { lancamentos } from "@/db/schema";
|
||||
import {
|
||||
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
|
||||
INITIAL_BALANCE_NOTE,
|
||||
} from "@/shared/lib/accounts/constants";
|
||||
import { db } from "@/shared/lib/db";
|
||||
import { getAdminPagadorId } from "@/shared/lib/payers/get-admin-id";
|
||||
import { safeToNumber as toNumber } from "@/shared/utils/number";
|
||||
|
||||
export type RecurringExpense = {
|
||||
id: string;
|
||||
name: string;
|
||||
amount: number;
|
||||
paymentMethod: string;
|
||||
recurrenceCount: number | null;
|
||||
};
|
||||
|
||||
export type RecurringExpensesData = {
|
||||
expenses: RecurringExpense[];
|
||||
};
|
||||
|
||||
export async function fetchRecurringExpenses(
|
||||
userId: string,
|
||||
period: string,
|
||||
): Promise<RecurringExpensesData> {
|
||||
const adminPagadorId = await getAdminPagadorId(userId);
|
||||
if (!adminPagadorId) {
|
||||
return { expenses: [] };
|
||||
}
|
||||
|
||||
const results = await db
|
||||
.select({
|
||||
id: lancamentos.id,
|
||||
name: lancamentos.name,
|
||||
amount: lancamentos.amount,
|
||||
paymentMethod: lancamentos.paymentMethod,
|
||||
recurrenceCount: lancamentos.recurrenceCount,
|
||||
})
|
||||
.from(lancamentos)
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.period, period),
|
||||
eq(lancamentos.transactionType, "Despesa"),
|
||||
eq(lancamentos.condition, "Recorrente"),
|
||||
eq(lancamentos.pagadorId, adminPagadorId),
|
||||
or(
|
||||
isNull(lancamentos.note),
|
||||
and(
|
||||
sql`${lancamentos.note} != ${INITIAL_BALANCE_NOTE}`,
|
||||
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(lancamentos.purchaseDate), desc(lancamentos.createdAt));
|
||||
|
||||
const expenses = results.map(
|
||||
(row): RecurringExpense => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
amount: Math.abs(toNumber(row.amount)),
|
||||
paymentMethod: row.paymentMethod,
|
||||
recurrenceCount: row.recurrenceCount,
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
expenses,
|
||||
};
|
||||
}
|
||||
82
src/features/dashboard/expenses/top-expenses-queries.ts
Normal file
82
src/features/dashboard/expenses/top-expenses-queries.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { and, asc, eq } from "drizzle-orm";
|
||||
import { cartoes, contas, lancamentos } from "@/db/schema";
|
||||
import {
|
||||
buildDashboardAdminPeriodFilters,
|
||||
excludeAutoGeneratedEntryNotes,
|
||||
} from "@/features/dashboard/lancamento-filters";
|
||||
import { db } from "@/shared/lib/db";
|
||||
import { getAdminPagadorId } from "@/shared/lib/payers/get-admin-id";
|
||||
import { safeToNumber as toNumber } from "@/shared/utils/number";
|
||||
|
||||
export type TopExpense = {
|
||||
id: string;
|
||||
name: string;
|
||||
amount: number;
|
||||
purchaseDate: Date;
|
||||
paymentMethod: string;
|
||||
logo?: string | null;
|
||||
};
|
||||
|
||||
export type TopExpensesData = {
|
||||
expenses: TopExpense[];
|
||||
};
|
||||
|
||||
export async function fetchTopExpenses(
|
||||
userId: string,
|
||||
period: string,
|
||||
cardOnly: boolean = false,
|
||||
): Promise<TopExpensesData> {
|
||||
const adminPagadorId = await getAdminPagadorId(userId);
|
||||
if (!adminPagadorId) {
|
||||
return { expenses: [] };
|
||||
}
|
||||
|
||||
const conditions = [
|
||||
...buildDashboardAdminPeriodFilters({
|
||||
userId,
|
||||
period,
|
||||
adminPagadorId,
|
||||
}),
|
||||
eq(lancamentos.transactionType, "Despesa"),
|
||||
excludeAutoGeneratedEntryNotes(),
|
||||
];
|
||||
|
||||
// Se cardOnly for true, filtra apenas pagamentos com cartão
|
||||
if (cardOnly) {
|
||||
conditions.push(eq(lancamentos.paymentMethod, "Cartão de Crédito"));
|
||||
}
|
||||
|
||||
const results = await db
|
||||
.select({
|
||||
id: lancamentos.id,
|
||||
name: lancamentos.name,
|
||||
amount: lancamentos.amount,
|
||||
purchaseDate: lancamentos.purchaseDate,
|
||||
paymentMethod: lancamentos.paymentMethod,
|
||||
cartaoId: lancamentos.cartaoId,
|
||||
contaId: lancamentos.contaId,
|
||||
cardLogo: cartoes.logo,
|
||||
accountLogo: contas.logo,
|
||||
})
|
||||
.from(lancamentos)
|
||||
.leftJoin(cartoes, eq(lancamentos.cartaoId, cartoes.id))
|
||||
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
|
||||
.where(and(...conditions))
|
||||
.orderBy(asc(lancamentos.amount))
|
||||
.limit(10);
|
||||
|
||||
const expenses = results.map(
|
||||
(row: (typeof results)[number]): 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,
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
expenses,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user