mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-09 11:01:45 +00:00
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>
246 lines
7.3 KiB
TypeScript
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,
|
|
},
|
|
};
|
|
};
|