"use server"; import { lancamentos, pagadores } from "@/db/schema"; import { db } from "@/lib/db"; import { getUser } from "@/lib/auth/server"; import { fetchPagadorBoletoStats, fetchPagadorCardUsage, fetchPagadorHistory, fetchPagadorMonthlyBreakdown, } from "@/lib/pagadores/details"; import { and, desc, eq } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import { Resend } from "resend"; import { z } from "zod"; const inputSchema = z.object({ pagadorId: z.string().uuid("Pagador inválido."), period: z .string() .regex(/^\d{4}-\d{2}$/, "Período inválido. Informe no formato AAAA-MM."), }); type ActionResult = | { success: true; message: string } | { success: false; error: string }; const formatCurrency = (value: number) => value.toLocaleString("pt-BR", { style: "currency", currency: "BRL", maximumFractionDigits: 2, }); const formatPeriodLabel = (period: string) => { const [yearStr, monthStr] = period.split("-"); const year = Number.parseInt(yearStr, 10); const month = Number.parseInt(monthStr, 10) - 1; const date = new Date(year, month, 1); return date.toLocaleDateString("pt-BR", { month: "long", year: "numeric", }); }; const formatDate = (value: Date | null | undefined) => { if (!value) return "—"; return value.toLocaleDateString("pt-BR", { day: "2-digit", month: "short", year: "numeric", }); }; // Escapa HTML para prevenir XSS const escapeHtml = (text: string | null | undefined): string => { if (!text) return ""; return text .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); }; type LancamentoRow = { id: string; name: string | null; paymentMethod: string | null; condition: string | null; amount: number; transactionType: string | null; purchaseDate: Date | null; }; type BoletoItem = { name: string; amount: number; dueDate: Date | null; }; type ParceladoItem = { name: string; totalAmount: number; installmentCount: number; currentInstallment: number; installmentAmount: number; purchaseDate: Date | null; }; type SummaryPayload = { pagadorName: string; periodLabel: string; monthlyBreakdown: Awaited>; historyData: Awaited>; cardUsage: Awaited>; boletoStats: Awaited>; boletos: BoletoItem[]; lancamentos: LancamentoRow[]; parcelados: ParceladoItem[]; }; const buildSectionHeading = (label: string) => `

${label}

`; const buildSummaryHtml = ({ pagadorName, periodLabel, monthlyBreakdown, historyData, cardUsage, boletoStats, boletos, lancamentos, parcelados, }: SummaryPayload) => { // Calcular máximo de despesas para barras de progresso const maxDespesas = Math.max(...historyData.map((p) => p.despesas), 1); const historyRows = historyData.length > 0 ? historyData .map((point) => { const percentage = (point.despesas / maxDespesas) * 100; const barColor = point.despesas > maxDespesas * 0.8 ? "#ef4444" : point.despesas > maxDespesas * 0.5 ? "#f59e0b" : "#10b981"; return ` ${escapeHtml( point.label )}
${formatCurrency( point.despesas )}
`; }) .join("") : `Sem histórico suficiente.`; const cardUsageRows = cardUsage.length > 0 ? cardUsage .map( (item) => ` ${escapeHtml( item.name )} ${formatCurrency( item.amount )} ` ) .join("") : `Sem gastos com cartão neste período.`; const boletoRows = boletos.length > 0 ? boletos .map( (item) => ` ${escapeHtml( item.name )} ${ item.dueDate ? formatDate(item.dueDate) : "—" } ${formatCurrency( item.amount )} ` ) .join("") : `Sem boletos neste período.`; const lancamentoRows = lancamentos.length > 0 ? lancamentos .map( (item) => ` ${formatDate( item.purchaseDate )} ${ escapeHtml(item.name) || "Sem descrição" } ${ escapeHtml(item.condition) || "—" } ${ escapeHtml(item.paymentMethod) || "—" } ${formatCurrency( item.amount )} ` ) .join("") : `Nenhum lançamento registrado no período.`; const parceladoRows = parcelados.length > 0 ? parcelados .map( (item) => ` ${formatDate( item.purchaseDate )} ${ escapeHtml(item.name) || "Sem descrição" } ${ item.currentInstallment }/${item.installmentCount} ${formatCurrency( item.installmentAmount )} ${formatCurrency( item.totalAmount )} ` ) .join("") : `Nenhum lançamento parcelado neste período.`; return `
Resumo mensal e detalhes de gastos por cartão, boletos e lançamentos.

Resumo Financeiro

${escapeHtml( periodLabel )}

Olá ${escapeHtml( pagadorName )}, segue o consolidado do mês:

${buildSectionHeading("💰 Totais do mês")}
Total gasto ${formatCurrency( monthlyBreakdown.totalExpenses )}
💳 Cartões ${formatCurrency( monthlyBreakdown.paymentSplits.card )}
📄 Boletos ${formatCurrency( monthlyBreakdown.paymentSplits.boleto )}
⚡ Pix/Débito/Dinheiro ${formatCurrency( monthlyBreakdown.paymentSplits.instant )}
${buildSectionHeading("📊 Evolução das Despesas (6 meses)")} ${historyRows}
Período Valor
${buildSectionHeading("💳 Gastos com Cartões")}
Total ${formatCurrency( monthlyBreakdown.paymentSplits.card )}
${cardUsageRows}
Cartão Valor
${buildSectionHeading("📄 Boletos")}
Total ${formatCurrency( boletoStats.totalAmount )}
${boletoRows}
Descrição Vencimento Valor
${buildSectionHeading("📝 Lançamentos do Mês")} ${lancamentoRows}
Data Descrição Condição Pagamento Valor
${buildSectionHeading("💳 Lançamentos Parcelados")} ${parceladoRows}
Data Descrição Parcela Valor Parcela Total

Este e-mail foi enviado automaticamente pelo OpenSheets.

`; }; export async function sendPagadorSummaryAction( input: z.infer ): Promise { try { const { pagadorId, period } = inputSchema.parse(input); const user = await getUser(); const pagadorRow = await db.query.pagadores.findFirst({ where: and(eq(pagadores.id, pagadorId), eq(pagadores.userId, user.id)), }); if (!pagadorRow) { return { success: false, error: "Pagador não encontrado." }; } if (!pagadorRow.email) { return { success: false, error: "Cadastre um e-mail para conseguir enviar o resumo.", }; } const resendApiKey = process.env.RESEND_API_KEY; const resendFrom = process.env.RESEND_FROM_EMAIL ?? "OpenSheets "; if (!resendApiKey) { return { success: false, error: "Serviço de e-mail não configurado (RESEND_API_KEY ausente).", }; } const resend = new Resend(resendApiKey); const [ monthlyBreakdown, historyData, cardUsage, boletoStats, boletoRows, lancamentoRows, parceladoRows, ] = await Promise.all([ fetchPagadorMonthlyBreakdown({ userId: user.id, pagadorId, period, }), fetchPagadorHistory({ userId: user.id, pagadorId, period, }), fetchPagadorCardUsage({ userId: user.id, pagadorId, period, }), fetchPagadorBoletoStats({ userId: user.id, pagadorId, period, }), db .select({ name: lancamentos.name, amount: lancamentos.amount, dueDate: lancamentos.dueDate, }) .from(lancamentos) .where( and( eq(lancamentos.userId, user.id), eq(lancamentos.pagadorId, pagadorId), eq(lancamentos.period, period), eq(lancamentos.paymentMethod, "Boleto") ) ) .orderBy(desc(lancamentos.dueDate)), db .select({ id: lancamentos.id, name: lancamentos.name, paymentMethod: lancamentos.paymentMethod, condition: lancamentos.condition, amount: lancamentos.amount, transactionType: lancamentos.transactionType, purchaseDate: lancamentos.purchaseDate, }) .from(lancamentos) .where( and( eq(lancamentos.userId, user.id), eq(lancamentos.pagadorId, pagadorId), eq(lancamentos.period, period) ) ) .orderBy(desc(lancamentos.purchaseDate)), db .select({ name: lancamentos.name, amount: lancamentos.amount, installmentCount: lancamentos.installmentCount, currentInstallment: lancamentos.currentInstallment, purchaseDate: lancamentos.purchaseDate, }) .from(lancamentos) .where( and( eq(lancamentos.userId, user.id), eq(lancamentos.pagadorId, pagadorId), eq(lancamentos.period, period), eq(lancamentos.condition, "Parcelado"), eq(lancamentos.isAnticipated, false) ) ) .orderBy(desc(lancamentos.purchaseDate)), ]); const normalizedBoletos: BoletoItem[] = boletoRows.map((row) => ({ name: row.name ?? "Sem descrição", amount: Math.abs(Number(row.amount ?? 0)), dueDate: row.dueDate, })); const normalizedLancamentos: LancamentoRow[] = lancamentoRows.map( (row) => ({ id: row.id, name: row.name, paymentMethod: row.paymentMethod, condition: row.condition, transactionType: row.transactionType, purchaseDate: row.purchaseDate, amount: Number(row.amount ?? 0), }) ); const normalizedParcelados: ParceladoItem[] = parceladoRows.map((row) => { const installmentAmount = Math.abs(Number(row.amount ?? 0)); const installmentCount = row.installmentCount ?? 1; const totalAmount = installmentAmount * installmentCount; return { name: row.name ?? "Sem descrição", installmentAmount, installmentCount, currentInstallment: row.currentInstallment ?? 1, totalAmount, purchaseDate: row.purchaseDate, }; }); const html = buildSummaryHtml({ pagadorName: pagadorRow.name, periodLabel: formatPeriodLabel(period), monthlyBreakdown, historyData, cardUsage, boletoStats, boletos: normalizedBoletos, lancamentos: normalizedLancamentos, parcelados: normalizedParcelados, }); await resend.emails.send({ from: resendFrom, to: pagadorRow.email, subject: `Resumo Financeiro | ${formatPeriodLabel(period)}`, html, }); const now = new Date(); await db .update(pagadores) .set({ lastMailAt: now }) .where( and(eq(pagadores.id, pagadorRow.id), eq(pagadores.userId, user.id)) ); revalidatePath(`/pagadores/${pagadorRow.id}`); return { success: true, message: "Resumo enviado com sucesso." }; } catch (error) { // Log estruturado em desenvolvimento if (process.env.NODE_ENV === "development") { console.error("[sendPagadorSummaryAction]", error); } // Tratar erros de validação separadamente if (error instanceof z.ZodError) { return { success: false, error: error.issues[0]?.message ?? "Dados inválidos.", }; } // Não expor detalhes do erro para o usuário return { success: false, error: "Não foi possível enviar o resumo. Tente novamente mais tarde.", }; } }