"use server"; import { randomUUID } from "node:crypto"; import { and, eq } from "drizzle-orm"; import { attachments, financialAccounts, transactionAttachments, transactions, } from "@/db/schema"; import { handleActionError } from "@/shared/lib/actions/helpers"; import { getUser } from "@/shared/lib/auth/server"; import { db } from "@/shared/lib/db"; import { buildEntriesByPayer, sendPayerAutoEmails, } from "@/shared/lib/payers/notifications"; import type { ActionResult } from "@/shared/lib/types/actions"; import { formatDecimalForDbRequired } from "@/shared/utils/currency"; import { getBusinessTodayDate, parseLocalDateString, } from "@/shared/utils/date"; import { cleanupAttachmentsAfterTransactionDelete } from "./attachments"; import { buildLancamentoRecords, buildShares, type CreateInput, centsToDecimalString, createSchema, type DeleteInput, deleteSchema, formatPaidInvoicePeriods, getPaidInvoicePeriods, isInitialBalanceLancamento, resolvePeriod, resolveUserLabel, revalidate, type ToggleSettlementInput, toggleSettlementSchema, type UpdateInput, updateSchema, validateAllOwnership, } from "./core"; export async function createTransactionAction( input: CreateInput, ): Promise> { try { const user = await getUser(); const data = createSchema.parse(input); const ownershipError = await validateAllOwnership(user.id, { payerId: data.payerId, secondaryPayerId: data.secondaryPayerId, categoryId: data.categoryId, accountId: data.accountId, cardId: data.cardId, }); if (ownershipError) { return { success: false, error: ownershipError }; } const period = resolvePeriod(data.purchaseDate, data.period); const purchaseDate = parseLocalDateString(data.purchaseDate); const dueDate = data.dueDate ? parseLocalDateString(data.dueDate) : null; const shouldSetBoletoPaymentDate = data.paymentMethod === "Boleto" && (data.isSettled ?? false); const boletoPaymentDate = shouldSetBoletoPaymentDate ? data.boletoPaymentDate ? parseLocalDateString(data.boletoPaymentDate) : getBusinessTodayDate() : null; const amountSign: 1 | -1 = data.transactionType === "Despesa" ? -1 : 1; const totalCents = Math.round(Math.abs(data.amount) * 100); const shouldNullifySettled = data.paymentMethod === "Cartão de crédito"; const shares = buildShares({ totalCents, payerId: data.payerId ?? null, isSplit: data.isSplit ?? false, secondaryPayerId: data.secondaryPayerId, primarySplitAmountCents: data.primarySplitAmount ? Math.round(data.primarySplitAmount * 100) : undefined, secondarySplitAmountCents: data.secondarySplitAmount ? Math.round(data.secondarySplitAmount * 100) : undefined, }); const isSeriesLancamento = data.condition === "Parcelado" || data.condition === "Recorrente"; const seriesId = isSeriesLancamento ? randomUUID() : null; const records = buildLancamentoRecords({ data, userId: user.id, period, purchaseDate, dueDate, shares, amountSign, shouldNullifySettled, boletoPaymentDate, seriesId, }); if (!records.length) { throw new Error("Não foi possível criar os lançamentos solicitados."); } if (data.cardId) { const uniquePeriods = [ ...new Set( records.map((r) => r.period).filter((p): p is string => Boolean(p)), ), ]; const paidPeriods = await getPaidInvoicePeriods( user.id, data.cardId, uniquePeriods, ); if (paidPeriods.length > 0) { return { success: false, error: `As faturas dos meses ${formatPaidInvoicePeriods( paidPeriods, )} já estão pagas. Desfaça o pagamento antes de adicionar este lançamento.`, } as ActionResult<{ ids: string[] }>; } } const inserted = await db .insert(transactions) .values(records) .returning({ id: transactions.id }); const notificationEntries = buildEntriesByPayer( records.map((record) => ({ payerId: record.payerId ?? null, name: record.name ?? null, amount: record.amount ?? null, transactionType: record.transactionType ?? null, paymentMethod: record.paymentMethod ?? null, condition: record.condition ?? null, purchaseDate: record.purchaseDate ?? null, period: record.period ?? null, note: record.note ?? null, })), ); if (notificationEntries.size > 0) { await sendPayerAutoEmails({ userLabel: resolveUserLabel(user), action: "created", entriesByPagador: notificationEntries, }); } revalidate(user.id); return { success: true, message: "Lançamento criado com sucesso.", data: { ids: inserted.map((r) => r.id) }, }; } catch (error) { return handleActionError(error) as ActionResult<{ ids: string[] }>; } } export async function updateTransactionAction( input: UpdateInput, ): Promise { try { const user = await getUser(); const data = updateSchema.parse(input); const ownershipError = await validateAllOwnership(user.id, { payerId: data.payerId, secondaryPayerId: data.secondaryPayerId, categoryId: data.categoryId, accountId: data.accountId, cardId: data.cardId, }); if (ownershipError) { return { success: false, error: ownershipError }; } const existing = (await db.query.transactions.findFirst({ columns: { id: true, note: true, period: true, transactionType: true, condition: true, paymentMethod: true, accountId: true, cardId: true, categoryId: true, }, where: and( eq(transactions.id, data.id), eq(transactions.userId, user.id), ), with: { category: { columns: { name: true, }, }, }, })) as | { id: string; note: string | null; period: string; transactionType: string; condition: string; paymentMethod: string; accountId: string | null; cardId: string | null; categoryId: string | null; category: { name: string } | null; } | undefined; if (!existing) { return { success: false, error: "Lançamento não encontrado." }; } const categoriasProtegidasEdicao = ["Saldo inicial", "Pagamentos"]; if ( existing.category?.name && categoriasProtegidasEdicao.includes(existing.category.name) ) { return { success: false, error: `Lançamentos com a categoria '${existing.category.name}' não podem ser editados.`, }; } const period = resolvePeriod(data.purchaseDate, data.period); const amountSign: 1 | -1 = data.transactionType === "Despesa" ? -1 : 1; const amountCents = Math.round(Math.abs(data.amount) * 100); const normalizedAmount = centsToDecimalString(amountCents * amountSign); const normalizedSettled = data.paymentMethod === "Cartão de crédito" ? null : (data.isSettled ?? false); const shouldSetBoletoPaymentDate = data.paymentMethod === "Boleto" && Boolean(normalizedSettled); const boletoPaymentDateValue = shouldSetBoletoPaymentDate ? data.boletoPaymentDate ? parseLocalDateString(data.boletoPaymentDate) : getBusinessTodayDate() : null; const targetCardId = data.cardId ?? existing.cardId; const movedInvoice = data.paymentMethod === "Cartão de crédito" && targetCardId && (targetCardId !== existing.cardId || period !== existing.period); if (movedInvoice) { const paidPeriods = await getPaidInvoicePeriods(user.id, targetCardId, [ period, ]); if (paidPeriods.length > 0) { return { success: false, error: `As faturas dos meses ${formatPaidInvoicePeriods( paidPeriods, )} já estão pagas. Desfaça o pagamento antes de mover este lançamento.`, }; } } await db .update(transactions) .set({ name: data.name, purchaseDate: parseLocalDateString(data.purchaseDate), transactionType: data.transactionType, amount: normalizedAmount, condition: data.condition, paymentMethod: data.paymentMethod, payerId: data.payerId ?? null, accountId: data.accountId ?? null, cardId: data.cardId ?? null, categoryId: data.categoryId ?? null, note: data.note ?? null, isSettled: normalizedSettled, installmentCount: data.installmentCount ?? null, recurrenceCount: data.recurrenceCount ?? null, dueDate: data.dueDate ? parseLocalDateString(data.dueDate) : null, boletoPaymentDate: boletoPaymentDateValue, period, }) .where( and(eq(transactions.id, data.id), eq(transactions.userId, user.id)), ); if (isInitialBalanceLancamento(existing) && existing.accountId) { const updatedInitialBalance = formatDecimalForDbRequired( Math.abs(data.amount ?? 0), ); await db .update(financialAccounts) .set({ initialBalance: updatedInitialBalance }) .where( and( eq(financialAccounts.id, existing.accountId), eq(financialAccounts.userId, user.id), ), ); } revalidate(user.id); return { success: true, message: "Lançamento atualizado com sucesso." }; } catch (error) { return handleActionError(error); } } export async function deleteTransactionAction( input: DeleteInput, ): Promise { try { const user = await getUser(); const data = deleteSchema.parse(input); const existing = (await db.query.transactions.findFirst({ columns: { id: true, name: true, payerId: true, amount: true, transactionType: true, paymentMethod: true, condition: true, purchaseDate: true, period: true, note: true, categoryId: true, }, where: and( eq(transactions.id, data.id), eq(transactions.userId, user.id), ), with: { category: { columns: { name: true, }, }, }, })) as | { id: string; name: string | null; payerId: string | null; amount: string | null; transactionType: string; paymentMethod: string; condition: string; purchaseDate: Date | null; period: string; note: string | null; categoryId: string | null; category: { name: string } | null; } | undefined; if (!existing) { return { success: false, error: "Lançamento não encontrado." }; } const categoriasProtegidasRemocao = ["Saldo inicial", "Pagamentos"]; if ( existing.category?.name && categoriasProtegidasRemocao.includes(existing.category.name) ) { return { success: false, error: `Lançamentos com a categoria '${existing.category.name}' não podem ser removidos.`, }; } const linkedAttachments = await db .select({ id: attachments.id, fileKey: attachments.fileKey }) .from(transactionAttachments) .innerJoin( attachments, eq(transactionAttachments.attachmentId, attachments.id), ) .where(eq(transactionAttachments.transactionId, data.id)); await db .delete(transactions) .where( and(eq(transactions.id, data.id), eq(transactions.userId, user.id)), ); await cleanupAttachmentsAfterTransactionDelete(linkedAttachments); if (existing.payerId) { const notificationEntries = buildEntriesByPayer([ { payerId: existing.payerId, name: existing.name ?? null, amount: existing.amount ?? null, transactionType: existing.transactionType ?? null, paymentMethod: existing.paymentMethod ?? null, condition: existing.condition ?? null, purchaseDate: existing.purchaseDate ?? null, period: existing.period ?? null, note: existing.note ?? null, }, ]); await sendPayerAutoEmails({ userLabel: resolveUserLabel(user), action: "deleted", entriesByPagador: notificationEntries, }); } revalidate(user.id); return { success: true, message: "Lançamento removido com sucesso." }; } catch (error) { return handleActionError(error); } } export async function toggleTransactionSettlementAction( input: ToggleSettlementInput, ): Promise { try { const user = await getUser(); const data = toggleSettlementSchema.parse(input); const existing = await db.query.transactions.findFirst({ columns: { id: true, paymentMethod: true }, where: and( eq(transactions.id, data.id), eq(transactions.userId, user.id), ), }); if (!existing) { return { success: false, error: "Lançamento não encontrado." }; } if (existing.paymentMethod === "Cartão de crédito") { return { success: false, error: "Pagamentos com cartão são conciliados automaticamente.", }; } const isBoleto = existing.paymentMethod === "Boleto"; const boletoPaymentDate = isBoleto ? data.value ? getBusinessTodayDate() : null : null; await db .update(transactions) .set({ isSettled: data.value, boletoPaymentDate, }) .where( and(eq(transactions.id, data.id), eq(transactions.userId, user.id)), ); revalidate(user.id); return { success: true, message: data.value ? "Lançamento marcado como pago." : "Pagamento desfeito com sucesso.", }; } catch (error) { return handleActionError(error); } }