mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-10 03:11:46 +00:00
fix(lançamentos): reforçar validações e revisar formulário
This commit is contained in:
@@ -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,
|
||||
})),
|
||||
);
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user