Files
openmonetis/src/features/calendar/queries.ts
Felipe Coutinho f747405264 feat(calendar): agrupar parcelas da mesma série em evento único
Lançamentos parcelados com o mesmo seriesId agora são consolidados em
um único evento do tipo 'installment' no calendário, exibindo 'Nx de
R$ X' em vez de repetir o mesmo item N vezes. Legenda e modal de
detalhes atualizados para refletir o novo tipo.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 19:50:02 +00:00

246 lines
7.3 KiB
TypeScript

import { and, eq, gte, lte, ne, or, sql } from "drizzle-orm";
import { cards, transactions } from "@/db/schema";
import {
buildOptionSets,
buildSluggedFilters,
mapTransactionsData,
} from "@/features/transactions/page-helpers";
import {
fetchRecentEstablishments,
fetchTransactionFilterSources,
} from "@/features/transactions/queries";
import { db } from "@/shared/lib/db";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
import type { CalendarData, CalendarEvent } from "@/shared/lib/types/calendar";
import { formatDateKey } from "@/shared/utils/calendar";
import { parsePeriod } from "@/shared/utils/period";
const PAYMENT_METHOD_BOLETO = "Boleto";
const TRANSACTION_TYPE_TRANSFERENCIA = "Transferência";
const PAYMENT_PREFIX = "Pagamento fatura - ";
const clampDayInMonth = (year: number, monthIndex: number, day: number) => {
const lastDay = new Date(Date.UTC(year, monthIndex + 1, 0)).getUTCDate();
if (day < 1) return 1;
if (day > lastDay) return lastDay;
return day;
};
const isWithinRange = (value: string | null, start: string, end: string) => {
if (!value) return false;
return value >= start && value <= end;
};
type FetchCalendarDataParams = {
userId: string;
period: string;
};
export const fetchCalendarData = async ({
userId,
period,
}: FetchCalendarDataParams): Promise<CalendarData> => {
const { year, month } = parsePeriod(period);
const monthIndex = month - 1;
const rangeStart = new Date(Date.UTC(year, monthIndex, 1));
const rangeEnd = new Date(Date.UTC(year, monthIndex + 1, 0));
const rangeStartKey = formatDateKey(rangeStart);
const rangeEndKey = formatDateKey(rangeEnd);
const adminPayerId = await getAdminPayerId(userId);
const [transactionRows, cardRows, filterSources] = await Promise.all([
db.query.transactions.findMany({
where: and(
eq(transactions.userId, userId),
adminPayerId ? eq(transactions.payerId, adminPayerId) : sql`false`,
ne(transactions.transactionType, TRANSACTION_TYPE_TRANSFERENCIA),
or(
// Lançamentos cuja data de compra esteja no período do calendário
and(
gte(transactions.purchaseDate, rangeStart),
lte(transactions.purchaseDate, rangeEnd),
),
// Boletos cuja data de vencimento esteja no período do calendário
and(
eq(transactions.paymentMethod, PAYMENT_METHOD_BOLETO),
gte(transactions.dueDate, rangeStart),
lte(transactions.dueDate, rangeEnd),
),
// Lançamentos de cartão do período (para calcular totais de vencimento)
and(
eq(transactions.period, period),
ne(transactions.paymentMethod, PAYMENT_METHOD_BOLETO),
),
),
),
with: {
payer: true,
financialAccount: true,
card: true,
category: true,
},
}),
db.query.cards.findMany({
where: eq(cards.userId, userId),
}),
fetchTransactionFilterSources(userId),
]);
const transactionData = mapTransactionsData(transactionRows);
const events: CalendarEvent[] = [];
// Totais por cartão para exibir no vencimento
const cardTotals = new Map<string, number>();
for (const item of transactionData) {
if (!item.cardId || item.period !== period) continue;
const amount = Math.abs(item.amount ?? 0);
cardTotals.set(item.cardId, (cardTotals.get(item.cardId) ?? 0) + amount);
}
// Pagamentos de fatura por nome do cartão → data de pagamento
const paymentByCardName = new Map<string, string | null>();
for (const item of transactionData) {
if (!item.name.startsWith(PAYMENT_PREFIX)) continue;
const cardName = item.name.slice(PAYMENT_PREFIX.length);
paymentByCardName.set(cardName, item.purchaseDate?.slice(0, 10) ?? null);
}
for (const item of transactionData) {
// Pagamentos de fatura são consumidos pelos eventos de cartão
if (item.name.startsWith(PAYMENT_PREFIX)) continue;
const isBoleto = item.paymentMethod === PAYMENT_METHOD_BOLETO;
if (isBoleto) {
if (
item.dueDate &&
isWithinRange(item.dueDate, rangeStartKey, rangeEndKey)
) {
events.push({
id: `${item.id}:boleto`,
type: "boleto",
date: item.dueDate,
transaction: item,
});
}
} else {
const purchaseDateKey = item.purchaseDate.slice(0, 10);
if (isWithinRange(purchaseDateKey, rangeStartKey, rangeEndKey)) {
events.push({
id: item.id,
type: "transaction",
date: purchaseDateKey,
transaction: item,
});
}
}
}
// Agrupar parcelas da mesma série em um único evento
const installmentGroups = new Map<
string,
Array<Extract<CalendarEvent, { type: "transaction" }>>
>();
for (const event of events) {
if (event.type !== "transaction") continue;
const { seriesId, installmentCount } = event.transaction;
if (!seriesId || !installmentCount || installmentCount <= 1) continue;
const group = installmentGroups.get(seriesId) ?? [];
group.push(event as Extract<CalendarEvent, { type: "transaction" }>);
installmentGroups.set(seriesId, group);
}
const groupedSeriesIds = new Set<string>();
const installmentEvents: CalendarEvent[] = [];
for (const [seriesId, group] of installmentGroups) {
if (group.length < 2) continue;
groupedSeriesIds.add(seriesId);
const rep = group[0];
installmentEvents.push({
id: `${seriesId}:installment`,
type: "installment",
date: rep.date,
transaction: rep.transaction,
installmentCount: rep.transaction.installmentCount ?? group.length,
installmentValue: rep.transaction.amount ?? 0,
});
}
const baseEvents = events.filter((e) => {
if (e.type !== "transaction") return true;
const { seriesId } = e.transaction;
return !seriesId || !groupedSeriesIds.has(seriesId);
});
const allEvents = [...baseEvents, ...installmentEvents];
// Vencimentos de cartões com lançamentos no período
for (const card of cardRows) {
if (!cardTotals.has(card.id)) continue;
const dueDayNumber = Number.parseInt(card.dueDay ?? "", 10);
if (Number.isNaN(dueDayNumber)) continue;
const normalizedDay = clampDayInMonth(year, monthIndex, dueDayNumber);
const dueDateKey = formatDateKey(
new Date(Date.UTC(year, monthIndex, normalizedDay)),
);
const isPaid = paymentByCardName.has(card.name);
const paymentDate = paymentByCardName.get(card.name) ?? null;
allEvents.push({
id: `${card.id}:cartao`,
type: "card",
date: dueDateKey,
card: {
id: card.id,
name: card.name,
dueDay: card.dueDay,
closingDay: card.closingDay,
brand: card.brand ?? null,
status: card.status,
logo: card.logo ?? null,
totalDue: cardTotals.get(card.id) ?? null,
isPaid,
paymentDate,
},
});
}
const typePriority: Record<CalendarEvent["type"], number> = {
transaction: 0,
installment: 0,
boleto: 1,
card: 2,
};
allEvents.sort((a, b) => {
if (a.date === b.date) {
return typePriority[a.type] - typePriority[b.type];
}
return a.date.localeCompare(b.date);
});
const sluggedFilters = buildSluggedFilters(filterSources);
const optionSets = buildOptionSets({
...sluggedFilters,
payerRows: filterSources.payerRows,
});
const estabelecimentos = await fetchRecentEstablishments(userId);
return {
events: allEvents,
formOptions: {
payerOptions: optionSets.payerOptions,
splitPayerOptions: optionSets.splitPayerOptions,
defaultPayerId: optionSets.defaultPayerId,
accountOptions: optionSets.accountOptions,
cardOptions: optionSets.cardOptions,
categoryOptions: optionSets.categoryOptions,
estabelecimentos,
},
};
};