mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-09 11:01:45 +00:00
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:
@@ -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,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user