Files
openmonetis/src/features/dashboard/expenses/installment-analysis-queries.ts
Felipe Coutinho 7d0781b035 refactor: faxina arquitetural — código morto, identificadores em inglês e estrutura padronizada
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>
2026-05-06 18:42:54 +00:00

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 };
}