fix(lançamentos): reforçar validações e revisar formulário

This commit is contained in:
Felipe Coutinho
2026-04-03 18:10:50 +00:00
parent 549a5bdba1
commit 1b4dfaaba7
12 changed files with 678 additions and 461 deletions

View File

@@ -16,6 +16,7 @@ import {
} from "@/shared/lib/payers/notifications";
import type { ActionResult } from "@/shared/lib/types/actions";
import { addMonthsToDate, parseLocalDateString } from "@/shared/utils/date";
import { addMonthsToPeriod, parsePeriod } from "@/shared/utils/period";
import {
centsToDecimalString,
type DeleteBulkInput,
@@ -26,6 +27,8 @@ import {
fetchOwnedCardIds,
fetchOwnedCategoryIds,
fetchOwnedPayerIds,
formatPaidInvoicePeriods,
getPaidInvoicePeriods,
type MassAddInput,
massAddSchema,
resolvePeriod,
@@ -37,6 +40,12 @@ import {
validateAllOwnership,
} from "./core";
const getPeriodOffset = (basePeriod: string, targetPeriod: string) => {
const base = parsePeriod(basePeriod);
const target = parsePeriod(targetPeriod);
return (target.year - base.year) * 12 + (target.month - base.month);
};
export async function deleteTransactionBulkAction(
input: DeleteBulkInput,
): Promise<ActionResult> {
@@ -164,8 +173,10 @@ export async function updateTransactionBulkAction(
period: true,
condition: true,
transactionType: true,
paymentMethod: true,
purchaseDate: true,
payerId: true,
cardId: true,
},
where: and(
eq(transactions.id, data.id),
@@ -204,6 +215,8 @@ export async function updateTransactionBulkAction(
const hasDueDateUpdate = data.dueDate !== undefined;
const hasBoletoPaymentDateUpdate = data.boletoPaymentDate !== undefined;
const hasPurchaseDateUpdate = data.purchaseDate !== undefined;
const hasPeriodUpdate = data.period !== undefined;
const baseDueDate =
hasDueDateUpdate && data.dueDate
@@ -218,8 +231,13 @@ export async function updateTransactionBulkAction(
: hasBoletoPaymentDateUpdate
? null
: undefined;
const basePurchaseDate = existing.purchaseDate ?? null;
const referencePurchaseDate = existing.purchaseDate ?? null;
const basePurchaseDate =
hasPurchaseDateUpdate && data.purchaseDate
? parseLocalDateString(data.purchaseDate)
: undefined;
const basePeriod = hasPeriodUpdate ? data.period : undefined;
const targetCardId = data.cardId ?? existing.cardId ?? null;
const buildDueDateForRecord = (recordPurchaseDate: Date | null) => {
if (!hasDueDateUpdate) {
@@ -230,18 +248,48 @@ export async function updateTransactionBulkAction(
return null;
}
if (!basePurchaseDate || !recordPurchaseDate) {
if (!referencePurchaseDate || !recordPurchaseDate) {
return baseDueDate;
}
const monthDiff =
(recordPurchaseDate.getFullYear() - basePurchaseDate.getFullYear()) *
(recordPurchaseDate.getFullYear() -
referencePurchaseDate.getFullYear()) *
12 +
(recordPurchaseDate.getMonth() - basePurchaseDate.getMonth());
(recordPurchaseDate.getMonth() - referencePurchaseDate.getMonth());
return addMonthsToDate(baseDueDate, monthDiff);
};
const buildPurchaseDateForRecord = (record: {
purchaseDate: Date | null;
period: string;
}) => {
if (!basePurchaseDate) {
return undefined;
}
if (existing.condition === "Recorrente" && existing.period) {
const offset = getPeriodOffset(existing.period, record.period);
return addMonthsToDate(basePurchaseDate, offset);
}
return basePurchaseDate;
};
const buildPeriodForRecord = (record: { period: string }) => {
if (!basePeriod) {
return undefined;
}
if (existing.period) {
const offset = getPeriodOffset(existing.period, record.period);
return addMonthsToPeriod(basePeriod, offset);
}
return basePeriod;
};
const serializeDateKey = (value: Date | null | undefined) => {
if (value === undefined) {
return "undefined";
@@ -252,8 +300,51 @@ export async function updateTransactionBulkAction(
return String(value.getTime());
};
const ensureTargetInvoicesAreOpen = async (
records: Array<{ period: string }>,
) => {
if (
existing.paymentMethod !== "Cartão de crédito" ||
!targetCardId ||
(!hasPurchaseDateUpdate &&
!hasPeriodUpdate &&
data.cardId === undefined)
) {
return null;
}
const movedPeriods = new Set<string>();
for (const record of records) {
const targetPeriodForRecord =
buildPeriodForRecord(record) ?? record.period;
const cardChanged = targetCardId !== existing.cardId;
const periodChanged = targetPeriodForRecord !== record.period;
if (cardChanged || periodChanged) {
movedPeriods.add(targetPeriodForRecord);
}
}
if (movedPeriods.size === 0) {
return null;
}
const paidPeriods = await getPaidInvoicePeriods(user.id, targetCardId, [
...movedPeriods,
]);
if (paidPeriods.length === 0) {
return null;
}
return `As faturas dos meses ${formatPaidInvoicePeriods(
paidPeriods,
)} já estão pagas. Desfaça o pagamento antes de mover este lançamento.`;
};
const applyUpdates = async (
records: Array<{ id: string; purchaseDate: Date | null }>,
records: Array<{ id: string; purchaseDate: Date | null; period: string }>,
) => {
if (records.length === 0) {
return;
@@ -269,10 +360,20 @@ export async function updateTransactionBulkAction(
for (const record of records) {
const dueDateForRecord = buildDueDateForRecord(record.purchaseDate);
const purchaseDateForRecord = buildPurchaseDateForRecord(record);
const periodForRecord = buildPeriodForRecord(record);
const perRecordPayload: Record<string, unknown> = {
...baseUpdatePayload,
};
if (purchaseDateForRecord !== undefined) {
perRecordPayload.purchaseDate = purchaseDateForRecord;
}
if (periodForRecord !== undefined) {
perRecordPayload.period = periodForRecord;
}
if (dueDateForRecord !== undefined) {
perRecordPayload.dueDate = dueDateForRecord;
}
@@ -282,6 +383,8 @@ export async function updateTransactionBulkAction(
}
const groupKey = [
serializeDateKey(purchaseDateForRecord),
periodForRecord ?? "undefined",
serializeDateKey(dueDateForRecord),
serializeDateKey(
hasBoletoPaymentDateUpdate
@@ -318,12 +421,19 @@ export async function updateTransactionBulkAction(
};
if (data.scope === "current") {
await applyUpdates([
const currentRecords = [
{
id: data.id,
purchaseDate: existing.purchaseDate ?? null,
period: existing.period,
},
]);
];
const invoiceError = await ensureTargetInvoicesAreOpen(currentRecords);
if (invoiceError) {
return { success: false, error: invoiceError };
}
await applyUpdates(currentRecords);
revalidate(user.id);
return { success: true, message: "Lançamento atualizado com sucesso." };
@@ -338,7 +448,7 @@ export async function updateTransactionBulkAction(
}
const periodLancamentos = await db.query.transactions.findMany({
columns: { id: true, purchaseDate: true },
columns: { id: true, purchaseDate: true, period: true },
where: and(
eq(transactions.seriesId, existing.seriesId),
eq(transactions.userId, user.id),
@@ -347,10 +457,16 @@ export async function updateTransactionBulkAction(
orderBy: asc(transactions.purchaseDate),
});
const invoiceError = await ensureTargetInvoicesAreOpen(periodLancamentos);
if (invoiceError) {
return { success: false, error: invoiceError };
}
await applyUpdates(
periodLancamentos.map((item: (typeof periodLancamentos)[number]) => ({
id: item.id,
purchaseDate: item.purchaseDate ?? null,
period: item.period,
})),
);
@@ -370,6 +486,7 @@ export async function updateTransactionBulkAction(
columns: {
id: true,
purchaseDate: true,
period: true,
},
where: and(
eq(transactions.seriesId, existing.seriesId),
@@ -380,10 +497,16 @@ export async function updateTransactionBulkAction(
orderBy: asc(transactions.purchaseDate),
});
const invoiceError = await ensureTargetInvoicesAreOpen(futureLancamentos);
if (invoiceError) {
return { success: false, error: invoiceError };
}
await applyUpdates(
futureLancamentos.map((item: (typeof futureLancamentos)[number]) => ({
id: item.id,
purchaseDate: item.purchaseDate ?? null,
period: item.period,
})),
);
@@ -399,6 +522,7 @@ export async function updateTransactionBulkAction(
columns: {
id: true,
purchaseDate: true,
period: true,
},
where: and(
eq(transactions.seriesId, existing.seriesId),
@@ -408,10 +532,16 @@ export async function updateTransactionBulkAction(
orderBy: asc(transactions.purchaseDate),
});
const invoiceError = await ensureTargetInvoicesAreOpen(allLancamentos);
if (invoiceError) {
return { success: false, error: invoiceError };
}
await applyUpdates(
allLancamentos.map((item: (typeof allLancamentos)[number]) => ({
id: item.id,
purchaseDate: item.purchaseDate ?? null,
period: item.period,
})),
);

View File

@@ -4,6 +4,7 @@ import {
cards,
categories,
financialAccounts,
invoices,
payers,
type transactions,
} from "@/db/schema";
@@ -20,9 +21,10 @@ import {
} from "@/shared/lib/accounts/constants";
import { revalidateForEntity } from "@/shared/lib/actions/helpers";
import { db } from "@/shared/lib/db";
import { INVOICE_PAYMENT_STATUS } from "@/shared/lib/invoices";
import { noteSchema, uuidSchema } from "@/shared/lib/schemas/common";
import { addMonthsToDate, parseLocalDateString } from "@/shared/utils/date";
import { addMonthsToPeriod } from "@/shared/utils/period";
import { addMonthsToPeriod, MONTH_NAMES } from "@/shared/utils/period";
// ============================================================================
// Authorization Validation Functions
@@ -662,6 +664,43 @@ export const buildLancamentoRecords = ({
return records;
};
export const formatPaidInvoicePeriods = (periods: string[]) =>
periods
.map((period) => {
const [year, month] = period.split("-");
const monthName = MONTH_NAMES[Number(month) - 1] ?? month;
return `${monthName}/${year}`;
})
.join(", ");
export async function getPaidInvoicePeriods(
userId: string,
cardId: string,
periods: string[],
) {
if (periods.length === 0) {
return [];
}
const rows = await db.query.invoices.findMany({
columns: { period: true },
where: and(
eq(invoices.userId, userId),
eq(invoices.cardId, cardId),
eq(invoices.paymentStatus, INVOICE_PAYMENT_STATUS.PAID),
inArray(invoices.period, periods),
),
});
return [
...new Set(
rows
.map((row) => row.period)
.filter((period): period is string => Boolean(period)),
),
];
}
export const deleteBulkSchema = z.object({
id: uuidSchema("Lançamento"),
scope: z.enum(["current", "period", "future", "all"], {
@@ -676,6 +715,20 @@ export const updateBulkSchema = z.object({
scope: z.enum(["current", "period", "future", "all"], {
message: "Escopo de ação inválido.",
}),
purchaseDate: z
.string()
.trim()
.refine((value) => !value || isValidDateInput(value), {
message: "Data da transação inválida.",
})
.optional(),
period: z
.string()
.trim()
.regex(/^(\d{4})-(\d{2})$/, {
message: "Selecione um período válido.",
})
.optional(),
name: z
.string({ message: "Informe o estabelecimento." })
.trim()

View File

@@ -1,18 +1,16 @@
"use server";
import { randomUUID } from "node:crypto";
import { and, eq, inArray } from "drizzle-orm";
import { and, eq } 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,
@@ -23,7 +21,6 @@ import {
getBusinessTodayDate,
parseLocalDateString,
} from "@/shared/utils/date";
import { MONTH_NAMES } from "@/shared/utils/period";
import { cleanupAttachmentsAfterTransactionDelete } from "./attachments";
import {
buildLancamentoRecords,
@@ -33,6 +30,8 @@ import {
createSchema,
type DeleteInput,
deleteSchema,
formatPaidInvoicePeriods,
getPaidInvoicePeriods,
isInitialBalanceLancamento,
resolvePeriod,
resolveUserLabel,
@@ -118,27 +117,18 @@ export async function createTransactionAction(
),
];
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),
),
});
const paidPeriods = await getPaidInvoicePeriods(
user.id,
data.cardId,
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(", ");
if (paidPeriods.length > 0) {
return {
success: false,
error: `As faturas dos meses ${labels} já estão pagas. Desfaça o pagamento antes de adicionar este lançamento.`,
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[] }>;
}
}
@@ -204,10 +194,12 @@ export async function updateTransactionAction(
columns: {
id: true,
note: true,
period: true,
transactionType: true,
condition: true,
paymentMethod: true,
accountId: true,
cardId: true,
categoryId: true,
},
where: and(
@@ -225,10 +217,12 @@ export async function updateTransactionAction(
| {
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;
}
@@ -264,6 +258,25 @@ export async function updateTransactionAction(
? 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)