mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-09 11:01:45 +00:00
Refatoração estrutural sem mudanças funcionais. Saldo líquido: −428 linhas. Removido: - 14 funções/constantes mortas verificadas via grep no repo todo: validateCategoriaOwnership, getInstallmentAnticipationsAction, getAnticipationDetailsAction, formatDecimalForDb, currencyFormatterNoCents, optionalDecimalSchema, formatMonthLabel, getGoalProgressStatusColorClass, MONTH_PERIOD_PARAM, calculateRemainingInstallments, e 5 funções fetch* não usadas em inbox/queries.ts. - 1 tipo morto (ImportRow) + 2 órfãos consequentes (InstallmentAnticipationWithRelations, GoalProgressStatus convertido em interno). - ~30 export keywords desnecessários (símbolos usados apenas no próprio arquivo). - Re-exports mortos em barrels: EstablishmentLogoPicker, CategoryReportSkeleton, WidgetSkeleton, toNameKey. - Arquivo features/reports/types.ts (barrel inteiro era órfão). Padronizado (PT-BR→EN em identificadores expostos): - 4 constantes globais (LANCAMENTOS_* → TRANSACTIONS_*). - 12 tipos/interfaces (Lancamento*/Pagador*/Estabelecimento* → equivalentes EN). - 13 funções/components exportados (fetchPagador*, EstabelecimentoInput, PagadorInfoCard, etc.). - 5 props cross-file (preLancamentosCount → inboxPendingCount, pagadorAvatarUrl → payerAvatarUrl, etc.). - Mantidas em PT-BR conforme exceção do CLAUDE.md: variáveis locais (pagador, categoria, lancamento), accessor key pagadorName (persistida em preferências), strings de UI. Reorganizado: - transactions/: 14 helpers soltos na raiz movidos para lib/; barrel actions.ts reduzido de 76 linhas de wrappers para 14 linhas de re-exports puros; anticipation-actions.ts movido para actions/anticipation.ts. - dashboard/: 8 helpers soltos consolidados em dashboard/lib/. - reports/: 5 query files na raiz consolidados em reports/lib/. - payers/: detail-actions.ts (21KB) e detail-queries.ts movidos para payers/lib/. - shared/components/: 9 dos 16 componentes soltos agrupados em brand/, widgets/, feedback/. - shared/lib/fetch-json.ts movido para shared/utils/fetch-json.ts. Validação: pnpm exec tsc --noEmit (0 erros), biome check (0 issues), knip (sem unused). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
187 lines
5.4 KiB
TypeScript
187 lines
5.4 KiB
TypeScript
import { and, eq, isNotNull, isNull, or, sql } from "drizzle-orm";
|
|
import { cards, transactions } from "@/db/schema";
|
|
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 {
|
|
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;
|
|
}
|
|
}
|
|
|
|
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;
|
|
cardId: 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> {
|
|
const adminPayerId = await getAdminPayerId(userId);
|
|
|
|
if (!adminPayerId) {
|
|
return { installmentGroups: [], totalPendingInstallments: 0 };
|
|
}
|
|
|
|
// 1. Buscar todos os lançamentos parcelados não antecipados da pessoa admin
|
|
const installmentRows = await db
|
|
.select({
|
|
id: transactions.id,
|
|
seriesId: transactions.seriesId,
|
|
name: transactions.name,
|
|
amount: transactions.amount,
|
|
paymentMethod: transactions.paymentMethod,
|
|
currentInstallment: transactions.currentInstallment,
|
|
installmentCount: transactions.installmentCount,
|
|
dueDate: transactions.dueDate,
|
|
period: transactions.period,
|
|
isAnticipated: transactions.isAnticipated,
|
|
isSettled: transactions.isSettled,
|
|
purchaseDate: transactions.purchaseDate,
|
|
cardId: transactions.cardId,
|
|
cartaoName: cards.name,
|
|
cartaoDueDay: cards.dueDay,
|
|
cartaoLogo: cards.logo,
|
|
})
|
|
.from(transactions)
|
|
.leftJoin(cards, eq(transactions.cardId, cards.id))
|
|
.where(
|
|
and(
|
|
eq(transactions.userId, userId),
|
|
eq(transactions.payerId, adminPayerId),
|
|
eq(transactions.transactionType, "Despesa"),
|
|
eq(transactions.condition, "Parcelado"),
|
|
eq(transactions.isAnticipated, false),
|
|
isNotNull(transactions.seriesId),
|
|
or(
|
|
isNull(transactions.note),
|
|
and(
|
|
sql`${transactions.note} != ${INITIAL_BALANCE_NOTE}`,
|
|
sql`${transactions.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
|
|
),
|
|
),
|
|
),
|
|
)
|
|
.orderBy(transactions.purchaseDate, transactions.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,
|
|
cardId: row.cardId,
|
|
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 };
|
|
}
|