From 00e624b8bc3a881a2ec759f5ee65a09b27fe8afc Mon Sep 17 00:00:00 2001 From: Felipe Coutinho Date: Sat, 28 Mar 2026 15:13:10 +0000 Subject: [PATCH] =?UTF-8?q?fix(lancamentos):=20bloquear=20cria=C3=A7=C3=A3?= =?UTF-8?q?o=20em=20fatura=20j=C3=A1=20paga=20no=20cart=C3=A3o=20de=20cr?= =?UTF-8?q?=C3=A9dito?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Evita divergência no relatório de análise de parcelas ao impedir o cadastro de lançamentos em períodos cujas faturas já foram quitadas. Co-Authored-By: Claude Sonnet 4.6 --- .../transactions/actions/single-actions.ts | 71 +++++++++++++++++-- .../transaction-dialog/transaction-dialog.tsx | 70 +++++++++++++++--- 2 files changed, 125 insertions(+), 16 deletions(-) diff --git a/src/features/transactions/actions/single-actions.ts b/src/features/transactions/actions/single-actions.ts index 121a821..74aa663 100644 --- a/src/features/transactions/actions/single-actions.ts +++ b/src/features/transactions/actions/single-actions.ts @@ -1,11 +1,18 @@ "use server"; import { randomUUID } from "node:crypto"; -import { and, eq } from "drizzle-orm"; -import { financialAccounts, transactions } from "@/db/schema"; +import { and, eq, inArray } from "drizzle-orm"; +import { + attachments, + financialAccounts, + invoices, + 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 { INVOICE_PAYMENT_STATUS } from "@/shared/lib/invoices"; import { buildEntriesByPayer, sendPayerAutoEmails, @@ -16,6 +23,8 @@ import { getBusinessTodayDate, parseLocalDateString, } from "@/shared/utils/date"; +import { MONTH_NAMES } from "@/shared/utils/period"; +import { cleanupAttachmentsAfterTransactionDelete } from "./attachments"; import { buildLancamentoRecords, buildShares, @@ -37,7 +46,7 @@ import { export async function createTransactionAction( input: CreateInput, -): Promise { +): Promise> { try { const user = await getUser(); const data = createSchema.parse(input); @@ -102,7 +111,42 @@ export async function createTransactionAction( throw new Error("Não foi possível criar os lançamentos solicitados."); } - await db.insert(transactions).values(records); + if (data.cardId) { + const uniquePeriods = [ + ...new Set( + records.map((r) => r.period).filter((p): p is string => Boolean(p)), + ), + ]; + + const paidInvoices = await db.query.invoices.findMany({ + columns: { period: true }, + where: and( + eq(invoices.userId, user.id), + eq(invoices.cardId, data.cardId), + eq(invoices.paymentStatus, INVOICE_PAYMENT_STATUS.PAID), + inArray(invoices.period, uniquePeriods), + ), + }); + + if (paidInvoices.length > 0) { + const labels = paidInvoices + .map((inv) => { + const [year, month] = (inv.period ?? "").split("-"); + const monthName = MONTH_NAMES[Number(month) - 1] ?? month; + return `${monthName}/${year}`; + }) + .join(", "); + return { + success: false, + error: `As faturas dos meses ${labels} 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) => ({ @@ -128,9 +172,13 @@ export async function createTransactionAction( revalidate(user.id); - return { success: true, message: "Lançamento criado com sucesso." }; + return { + success: true, + message: "Lançamento criado com sucesso.", + data: { ids: inserted.map((r) => r.id) }, + }; } catch (error) { - return handleActionError(error); + return handleActionError(error) as ActionResult<{ ids: string[] }>; } } @@ -329,12 +377,23 @@ export async function deleteTransactionAction( }; } + 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([ { diff --git a/src/features/transactions/components/dialogs/transaction-dialog/transaction-dialog.tsx b/src/features/transactions/components/dialogs/transaction-dialog/transaction-dialog.tsx index a9bf316..dab8cc9 100644 --- a/src/features/transactions/components/dialogs/transaction-dialog/transaction-dialog.tsx +++ b/src/features/transactions/components/dialogs/transaction-dialog/transaction-dialog.tsx @@ -6,6 +6,10 @@ import { createTransactionAction, updateTransactionAction, } from "@/features/transactions/actions"; +import { + confirmAttachmentUploadAction, + getPresignedUploadUrlAction, +} from "@/features/transactions/actions/attachments"; import { filterSecondaryPayerOptions, groupAndSortCategories, @@ -30,7 +34,10 @@ import { DialogTitle, DialogTrigger, } from "@/shared/components/ui/dialog"; +import { Label } from "@/shared/components/ui/label"; import { useControlledState } from "@/shared/hooks/use-controlled-state"; +import { AttachmentFilePicker } from "../../attachments/attachment-file-picker"; +import { AttachmentSection } from "../../attachments/attachment-section"; import { BasicFieldsSection } from "./basic-fields-section"; import { BoletoFieldsSection } from "./boleto-fields-section"; import { CategorySection } from "./category-section"; @@ -90,6 +97,7 @@ export function TransactionDialog({ ); const [isPending, startTransition] = useTransition(); const [errorMessage, setErrorMessage] = useState(null); + const [pendingFile, setPendingFile] = useState(null); useEffect(() => { if (dialogOpen) { @@ -126,6 +134,7 @@ export function TransactionDialog({ setFormState(initial); setErrorMessage(null); + setPendingFile(null); } }, [ dialogOpen, @@ -313,6 +322,29 @@ export function TransactionDialog({ const result = await createTransactionAction(payload); if (result.success) { + if (pendingFile && result.data?.ids?.length) { + const firstId = result.data.ids[0]; + const isNewSeries = + formState.condition === "Parcelado" || + formState.condition === "Recorrente"; + const presign = await getPresignedUploadUrlAction({ + fileName: pendingFile.name, + mimeType: pendingFile.type, + fileSize: pendingFile.size, + transactionId: firstId, + }); + if (presign.success) { + await fetch(presign.presignedUrl, { + method: "PUT", + body: pendingFile, + headers: { "Content-Type": pendingFile.type }, + }); + await confirmAttachmentUploadAction({ + uploadToken: presign.uploadToken, + applyToSeries: isNewSeries, + }); + } + } toast.success(result.message); onSuccess?.(); setDialogOpen(false); @@ -415,18 +447,18 @@ export function TransactionDialog({ return ( {trigger ? {trigger} : null} - + {title} {description}
-
+
+ <> + +
+ + +
+ ) : ( - + - Condições e anotações + Condições, anotações e anexos - + + )}