feat(notificações): alertas de vencimento para o período seguinte

Boletos e faturas do próximo período com vencimento dentro de 5 dias
agora geram notificações do tipo `due_soon`, evitando duplicatas com
notificações já existentes do período corrente.

A query de boletos passa a filtrar pela data de vencimento não nula e
limita a janela de busca a 12 meses anteriores ao período atual.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Felipe Coutinho
2026-04-01 14:14:07 +00:00
parent 1f8a97bd16
commit 96df6a1798

View File

@@ -1,6 +1,4 @@
"use server"; import { and, eq, gte, inArray, isNotNull, lt, ne, sql } from "drizzle-orm";
import { and, eq, inArray, lt, ne, sql } from "drizzle-orm";
import { import {
budgets, budgets,
cards, cards,
@@ -27,7 +25,11 @@ import {
toDateOnlyString, toDateOnlyString,
} from "@/shared/utils/date"; } from "@/shared/utils/date";
import { safeToNumber as toNumber } from "@/shared/utils/number"; import { safeToNumber as toNumber } from "@/shared/utils/number";
import { formatPeriodForUrl } from "@/shared/utils/period"; import {
addMonthsToPeriod,
formatPeriodForUrl,
getNextPeriod,
} from "@/shared/utils/period";
export type { export type {
BudgetNotification, BudgetNotification,
@@ -98,6 +100,7 @@ export async function fetchDashboardNotifications(
): Promise<DashboardNotificationsSnapshot> { ): Promise<DashboardNotificationsSnapshot> {
const today = getBusinessDateString(); const today = getBusinessDateString();
const DAYS_THRESHOLD = 5; const DAYS_THRESHOLD = 5;
const nextPeriod = getNextPeriod(currentPeriod);
const adminPayerId = await getAdminPayerId(userId); const adminPayerId = await getAdminPayerId(userId);
@@ -110,6 +113,10 @@ export async function fetchDashboardNotifications(
if (adminPayerId) { if (adminPayerId) {
boletosConditions.push(eq(transactions.payerId, adminPayerId)); boletosConditions.push(eq(transactions.payerId, adminPayerId));
} }
boletosConditions.push(isNotNull(transactions.dueDate));
boletosConditions.push(
gte(transactions.period, addMonthsToPeriod(currentPeriod, -12)),
);
const budgetJoinConditions = [ const budgetJoinConditions = [
eq(transactions.categoryId, budgets.categoryId), eq(transactions.categoryId, budgets.categoryId),
@@ -122,118 +129,126 @@ export async function fetchDashboardNotifications(
budgetJoinConditions.push(eq(transactions.payerId, adminPayerId)); budgetJoinConditions.push(eq(transactions.payerId, adminPayerId));
} }
// --- All 4 queries are independent — run in parallel --- // Helper: monta a query de faturas por período (reutilizada para período atual e próximo)
const [overdueInvoices, currentInvoices, boletosRows, budgetRows] = const buildPeriodInvoicesQuery = (period: string) =>
await Promise.all([ db
// Faturas atrasadas (períodos anteriores) .select({
db invoiceId: invoices.id,
.select({ cardId: cards.id,
invoiceId: invoices.id, cardName: cards.name,
cardId: cards.id, cardLogo: cards.logo,
cardName: cards.name, dueDay: cards.dueDay,
cardLogo: cards.logo, period: sql<string>`COALESCE(${invoices.period}, ${period})`,
dueDay: cards.dueDay, paymentStatus: invoices.paymentStatus,
period: invoices.period, totalAmount: sql<number | null>`
totalAmount: sql<
number | null
>`COALESCE(SUM(${transactions.amount}), 0)`,
})
.from(invoices)
.innerJoin(cards, eq(invoices.cardId, cards.id))
.leftJoin(
transactions,
and(
eq(transactions.cardId, invoices.cardId),
eq(transactions.period, invoices.period),
eq(transactions.userId, invoices.userId),
),
)
.where(
and(
eq(invoices.userId, userId),
eq(invoices.paymentStatus, INVOICE_PAYMENT_STATUS.PENDING),
lt(invoices.period, currentPeriod),
),
)
.groupBy(
invoices.id,
cards.id,
cards.name,
cards.logo,
cards.dueDay,
invoices.period,
),
// Faturas do período atual
db
.select({
invoiceId: invoices.id,
cardId: cards.id,
cardName: cards.name,
cardLogo: cards.logo,
dueDay: cards.dueDay,
period: sql<string>`COALESCE(${invoices.period}, ${currentPeriod})`,
paymentStatus: invoices.paymentStatus,
totalAmount: sql<number | null>`
COALESCE(SUM(${transactions.amount}), 0) COALESCE(SUM(${transactions.amount}), 0)
`, `,
transactionCount: sql<number | null>`COUNT(${transactions.id})`, transactionCount: sql<number | null>`COUNT(${transactions.id})`,
}) })
.from(cards) .from(cards)
.leftJoin( .leftJoin(
invoices, invoices,
and( and(
eq(invoices.cardId, cards.id), eq(invoices.cardId, cards.id),
eq(invoices.userId, userId), eq(invoices.userId, userId),
eq(invoices.period, currentPeriod), eq(invoices.period, period),
),
)
.leftJoin(
transactions,
and(
eq(transactions.cardId, cards.id),
eq(transactions.userId, userId),
eq(transactions.period, currentPeriod),
),
)
.where(eq(cards.userId, userId))
.groupBy(
invoices.id,
cards.id,
cards.name,
cards.logo,
cards.dueDay,
invoices.period,
invoices.paymentStatus,
), ),
// Boletos não pagos )
db .leftJoin(
.select({ transactions,
id: transactions.id, and(
name: transactions.name, eq(transactions.cardId, cards.id),
amount: transactions.amount, eq(transactions.userId, userId),
dueDate: transactions.dueDate, eq(transactions.period, period),
period: transactions.period, ),
}) )
.from(transactions) .where(eq(cards.userId, userId))
.where(and(...boletosConditions)), .groupBy(
// Orçamentos do período atual invoices.id,
db cards.id,
.select({ cards.name,
orcamentoId: budgets.id, cards.logo,
categoryId: budgets.categoryId, cards.dueDay,
budgetAmount: budgets.amount, invoices.period,
period: budgets.period, invoices.paymentStatus,
categoriaName: categories.name, );
spentAmount: sql<number>`COALESCE(SUM(ABS(${transactions.amount})), 0)`,
}) // --- All 5 queries are independent — run in parallel ---
.from(budgets) const [
.innerJoin(categories, eq(budgets.categoryId, categories.id)) overdueInvoices,
.leftJoin(transactions, and(...budgetJoinConditions)) currentInvoices,
.where( nextPeriodInvoices,
and(eq(budgets.userId, userId), eq(budgets.period, currentPeriod)), boletosRows,
) budgetRows,
.groupBy(budgets.id, budgets.amount, categories.name), ] = await Promise.all([
]); // Faturas atrasadas (períodos anteriores)
db
.select({
invoiceId: invoices.id,
cardId: cards.id,
cardName: cards.name,
cardLogo: cards.logo,
dueDay: cards.dueDay,
period: invoices.period,
totalAmount: sql<
number | null
>`COALESCE(SUM(${transactions.amount}), 0)`,
})
.from(invoices)
.innerJoin(cards, eq(invoices.cardId, cards.id))
.leftJoin(
transactions,
and(
eq(transactions.cardId, invoices.cardId),
eq(transactions.period, invoices.period),
eq(transactions.userId, invoices.userId),
),
)
.where(
and(
eq(invoices.userId, userId),
eq(invoices.paymentStatus, INVOICE_PAYMENT_STATUS.PENDING),
lt(invoices.period, currentPeriod),
),
)
.groupBy(
invoices.id,
cards.id,
cards.name,
cards.logo,
cards.dueDay,
invoices.period,
),
// Faturas do período atual e próximo
buildPeriodInvoicesQuery(currentPeriod),
buildPeriodInvoicesQuery(nextPeriod),
// Boletos não pagos
db
.select({
id: transactions.id,
name: transactions.name,
amount: transactions.amount,
dueDate: transactions.dueDate,
period: transactions.period,
})
.from(transactions)
.where(and(...boletosConditions)),
// Orçamentos do período atual
db
.select({
orcamentoId: budgets.id,
categoryId: budgets.categoryId,
budgetAmount: budgets.amount,
period: budgets.period,
categoriaName: categories.name,
spentAmount: sql<number>`COALESCE(SUM(ABS(${transactions.amount})), 0)`,
})
.from(budgets)
.innerJoin(categories, eq(budgets.categoryId, categories.id))
.leftJoin(transactions, and(...budgetJoinConditions))
.where(and(eq(budgets.userId, userId), eq(budgets.period, currentPeriod)))
.groupBy(budgets.id, budgets.amount, categories.name),
]);
// ===================== // =====================
// Processar notificações // Processar notificações
@@ -327,6 +342,53 @@ export async function fetchDashboardNotifications(
}); });
} }
// Faturas do próximo período com vencimento próximo
const addedNotificationKeys = new Set(
notifications.map((n) => n.notificationKey),
);
for (const invoice of nextPeriodInvoices) {
if (!invoice.dueDay) continue;
const dueDate = buildDateOnlyStringFromPeriodDay(
nextPeriod,
invoice.dueDay,
);
if (!dueDate) continue;
if (invoice.paymentStatus === INVOICE_PAYMENT_STATUS.PAID) continue;
const invoiceIsDueSoon = isDateOnlyWithinDays(
dueDate,
DAYS_THRESHOLD,
today,
);
if (!invoiceIsDueSoon) continue;
const notificationKey = buildInvoiceNotificationKey(
invoice.cardId,
nextPeriod,
);
// Evitar duplicata se já foi adicionado via currentInvoices
if (addedNotificationKeys.has(notificationKey)) continue;
const amount = toNumber(invoice.totalAmount);
notifications.push({
type: "invoice",
name: invoice.cardName,
dueDate,
status: "due_soon",
amount: Math.abs(amount),
period: nextPeriod,
showAmount: false,
cardLogo: invoice.cardLogo,
notificationKey,
fingerprint: "due_soon",
href: buildInvoiceDetailsHref(invoice.cardId, nextPeriod),
isRead: false,
isArchived: false,
readAt: null,
archivedAt: null,
});
}
// Boletos // Boletos
for (const boleto of boletosRows) { for (const boleto of boletosRows) {
const dueDate = toDateOnlyString(boleto.dueDate); const dueDate = toDateOnlyString(boleto.dueDate);
@@ -340,6 +402,7 @@ export async function fetchDashboardNotifications(
); );
const isOldPeriod = boleto.period < currentPeriod; const isOldPeriod = boleto.period < currentPeriod;
const isCurrentPeriod = boleto.period === currentPeriod; const isCurrentPeriod = boleto.period === currentPeriod;
const isNextPeriod = boleto.period === nextPeriod;
const amount = toNumber(boleto.amount); const amount = toNumber(boleto.amount);
const href = `/transactions?periodo=${formatPeriodForUrl(boleto.period)}`; const href = `/transactions?periodo=${formatPeriodForUrl(boleto.period)}`;
const notificationKey = buildBoletoNotificationKey(boleto.id); const notificationKey = buildBoletoNotificationKey(boleto.id);
@@ -380,6 +443,23 @@ export async function fetchDashboardNotifications(
readAt: null, readAt: null,
archivedAt: null, archivedAt: null,
}); });
} else if (isNextPeriod && boletoIsDueSoon) {
notifications.push({
type: "boleto",
name: boleto.name,
dueDate,
status: "due_soon",
amount: Math.abs(amount),
period: boleto.period,
showAmount: false,
notificationKey,
fingerprint: "due_soon",
href,
isRead: false,
isArchived: false,
readAt: null,
archivedAt: null,
});
} }
} }