refactor: pagina transações e modulariza ações

This commit is contained in:
Felipe Coutinho
2026-03-20 18:39:49 +00:00
parent 41fd8226cb
commit 3c31ee5d90
20 changed files with 4261 additions and 1782 deletions

View File

@@ -0,0 +1,624 @@
"use server";
import { and, asc, eq, inArray, sql } from "drizzle-orm";
import { transactions } from "@/db/schema";
import {
PAYMENT_METHODS,
TRANSACTION_CONDITIONS,
TRANSACTION_TYPES,
} from "@/features/transactions/constants";
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 { addMonthsToDate, parseLocalDateString } from "@/shared/utils/date";
import {
centsToDecimalString,
type DeleteBulkInput,
type DeleteMultipleInput,
deleteBulkSchema,
deleteMultipleSchema,
fetchOwnedAccountIds,
fetchOwnedCardIds,
fetchOwnedCategoryIds,
fetchOwnedPayerIds,
type MassAddInput,
massAddSchema,
resolvePeriod,
resolveUserLabel,
revalidate,
type TransactionInsert,
type UpdateBulkInput,
updateBulkSchema,
validateAllOwnership,
} from "./core";
export async function deleteTransactionBulkAction(
input: DeleteBulkInput,
): Promise<ActionResult> {
try {
const user = await getUser();
const data = deleteBulkSchema.parse(input);
const existing = await db.query.transactions.findFirst({
columns: {
id: true,
name: true,
seriesId: true,
period: true,
condition: 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.seriesId) {
return {
success: false,
error: "Este lançamento não faz parte de uma série.",
};
}
if (data.scope === "current") {
await db
.delete(transactions)
.where(
and(eq(transactions.id, data.id), eq(transactions.userId, user.id)),
);
revalidate(user.id);
return { success: true, message: "Lançamento removido com sucesso." };
}
if (data.scope === "future") {
await db
.delete(transactions)
.where(
and(
eq(transactions.seriesId, existing.seriesId),
eq(transactions.userId, user.id),
sql`${transactions.period} >= ${existing.period}`,
),
);
revalidate(user.id);
return {
success: true,
message: "Lançamentos removidos com sucesso.",
};
}
if (data.scope === "all") {
await db
.delete(transactions)
.where(
and(
eq(transactions.seriesId, existing.seriesId),
eq(transactions.userId, user.id),
),
);
revalidate(user.id);
return {
success: true,
message: "Todos os lançamentos da série foram removidos.",
};
}
return { success: false, error: "Escopo de ação inválido." };
} catch (error) {
return handleActionError(error);
}
}
export async function updateTransactionBulkAction(
input: UpdateBulkInput,
): Promise<ActionResult> {
try {
const user = await getUser();
const data = updateBulkSchema.parse(input);
const ownershipError = await validateAllOwnership(user.id, {
payerId: data.payerId,
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,
name: true,
seriesId: true,
period: true,
condition: true,
transactionType: true,
purchaseDate: 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.seriesId) {
return {
success: false,
error: "Este lançamento não faz parte de uma série.",
};
}
const baseUpdatePayload: Record<string, unknown> = {
name: data.name,
categoryId: data.categoryId ?? null,
note: data.note ?? null,
payerId: data.payerId ?? null,
accountId: data.accountId ?? null,
cardId: data.cardId ?? null,
};
if (data.amount !== undefined) {
const amountSign: 1 | -1 =
existing.transactionType === "Despesa" ? -1 : 1;
const amountCents = Math.round(Math.abs(data.amount) * 100);
baseUpdatePayload.amount = centsToDecimalString(amountCents * amountSign);
}
const hasDueDateUpdate = data.dueDate !== undefined;
const hasBoletoPaymentDateUpdate = data.boletoPaymentDate !== undefined;
const baseDueDate =
hasDueDateUpdate && data.dueDate
? parseLocalDateString(data.dueDate)
: hasDueDateUpdate
? null
: undefined;
const baseBoletoPaymentDate =
hasBoletoPaymentDateUpdate && data.boletoPaymentDate
? parseLocalDateString(data.boletoPaymentDate)
: hasBoletoPaymentDateUpdate
? null
: undefined;
const basePurchaseDate = existing.purchaseDate ?? null;
const buildDueDateForRecord = (recordPurchaseDate: Date | null) => {
if (!hasDueDateUpdate) {
return undefined;
}
if (!baseDueDate) {
return null;
}
if (!basePurchaseDate || !recordPurchaseDate) {
return baseDueDate;
}
const monthDiff =
(recordPurchaseDate.getFullYear() - basePurchaseDate.getFullYear()) *
12 +
(recordPurchaseDate.getMonth() - basePurchaseDate.getMonth());
return addMonthsToDate(baseDueDate, monthDiff);
};
const serializeDateKey = (value: Date | null | undefined) => {
if (value === undefined) {
return "undefined";
}
if (value === null) {
return "null";
}
return String(value.getTime());
};
const applyUpdates = async (
records: Array<{ id: string; purchaseDate: Date | null }>,
) => {
if (records.length === 0) {
return;
}
const groupedPayloads = new Map<
string,
{
ids: string[];
payload: Record<string, unknown>;
}
>();
for (const record of records) {
const dueDateForRecord = buildDueDateForRecord(record.purchaseDate);
const perRecordPayload: Record<string, unknown> = {
...baseUpdatePayload,
};
if (dueDateForRecord !== undefined) {
perRecordPayload.dueDate = dueDateForRecord;
}
if (hasBoletoPaymentDateUpdate) {
perRecordPayload.boletoPaymentDate = baseBoletoPaymentDate ?? null;
}
const groupKey = [
serializeDateKey(dueDateForRecord),
serializeDateKey(
hasBoletoPaymentDateUpdate
? (baseBoletoPaymentDate ?? null)
: undefined,
),
].join("|");
const group = groupedPayloads.get(groupKey);
if (group) {
group.ids.push(record.id);
continue;
}
groupedPayloads.set(groupKey, {
ids: [record.id],
payload: perRecordPayload,
});
}
await db.transaction(async (tx: typeof db) => {
for (const group of groupedPayloads.values()) {
await tx
.update(transactions)
.set(group.payload)
.where(
and(
inArray(transactions.id, group.ids),
eq(transactions.userId, user.id),
),
);
}
});
};
if (data.scope === "current") {
await applyUpdates([
{
id: data.id,
purchaseDate: existing.purchaseDate ?? null,
},
]);
revalidate(user.id);
return { success: true, message: "Lançamento atualizado com sucesso." };
}
if (data.scope === "future") {
const futureLancamentos = await db.query.transactions.findMany({
columns: {
id: true,
purchaseDate: true,
},
where: and(
eq(transactions.seriesId, existing.seriesId),
eq(transactions.userId, user.id),
sql`${transactions.period} >= ${existing.period}`,
),
orderBy: asc(transactions.purchaseDate),
});
await applyUpdates(
futureLancamentos.map((item: (typeof futureLancamentos)[number]) => ({
id: item.id,
purchaseDate: item.purchaseDate ?? null,
})),
);
revalidate(user.id);
return {
success: true,
message: "Lançamentos atualizados com sucesso.",
};
}
if (data.scope === "all") {
const allLancamentos = await db.query.transactions.findMany({
columns: {
id: true,
purchaseDate: true,
},
where: and(
eq(transactions.seriesId, existing.seriesId),
eq(transactions.userId, user.id),
),
orderBy: asc(transactions.purchaseDate),
});
await applyUpdates(
allLancamentos.map((item: (typeof allLancamentos)[number]) => ({
id: item.id,
purchaseDate: item.purchaseDate ?? null,
})),
);
revalidate(user.id);
return {
success: true,
message: "Todos os lançamentos da série foram atualizados.",
};
}
return { success: false, error: "Escopo de ação inválido." };
} catch (error) {
return handleActionError(error);
}
}
export async function createMassTransactionsAction(
input: MassAddInput,
): Promise<ActionResult> {
try {
const user = await getUser();
const data = massAddSchema.parse(input);
const uniquePayerIds = new Set<string>();
const uniqueCategoryIds = new Set<string>();
for (const transaction of data.transactions) {
if (transaction.payerId) uniquePayerIds.add(transaction.payerId);
if (transaction.categoryId) uniqueCategoryIds.add(transaction.categoryId);
}
const [ownedAccountIds, ownedCardIds, ownedPayerIds, ownedCategoryIds] =
await Promise.all([
fetchOwnedAccountIds(user.id, [data.fixedFields.accountId]),
fetchOwnedCardIds(user.id, [data.fixedFields.cardId]),
fetchOwnedPayerIds(user.id, [...uniquePayerIds]),
fetchOwnedCategoryIds(user.id, [...uniqueCategoryIds]),
]);
if (
data.fixedFields.accountId &&
!ownedAccountIds.has(data.fixedFields.accountId)
) {
return { success: false, error: "Conta não encontrada." };
}
if (data.fixedFields.cardId && !ownedCardIds.has(data.fixedFields.cardId)) {
return { success: false, error: "Cartão não encontrado." };
}
const invalidPayers = new Set(
[...uniquePayerIds].filter((id) => !ownedPayerIds.has(id)),
);
const invalidCategories = new Set(
[...uniqueCategoryIds].filter((id) => !ownedCategoryIds.has(id)),
);
for (let i = 0; i < data.transactions.length; i++) {
const transaction = data.transactions[i];
if (transaction.payerId && invalidPayers.has(transaction.payerId)) {
return {
success: false,
error: `Payer não encontrado na transação ${i + 1}.`,
};
}
if (
transaction.categoryId &&
invalidCategories.has(transaction.categoryId)
) {
return {
success: false,
error: `Category não encontrada na transação ${i + 1}.`,
};
}
}
const defaultTransactionType = TRANSACTION_TYPES[0];
const defaultCondition = TRANSACTION_CONDITIONS[0];
const defaultPaymentMethod = PAYMENT_METHODS[0];
const allRecords: TransactionInsert[] = [];
const notificationData: Array<{
payerId: string | null;
name: string | null;
amount: string | null;
transactionType: string | null;
paymentMethod: string | null;
condition: string | null;
purchaseDate: Date | null;
period: string | null;
note: string | null;
}> = [];
for (const transaction of data.transactions) {
const transactionType =
data.fixedFields.transactionType ?? defaultTransactionType;
const condition = data.fixedFields.condition ?? defaultCondition;
const paymentMethod =
data.fixedFields.paymentMethod ?? defaultPaymentMethod;
const payerId = transaction.payerId ?? null;
const accountId =
paymentMethod === "Cartão de crédito"
? null
: (data.fixedFields.accountId ?? null);
const cardId =
paymentMethod === "Cartão de crédito"
? (data.fixedFields.cardId ?? null)
: null;
const categoryId = transaction.categoryId ?? null;
const period =
data.fixedFields.period ?? resolvePeriod(transaction.purchaseDate);
const purchaseDate = parseLocalDateString(transaction.purchaseDate);
const amountSign: 1 | -1 = transactionType === "Despesa" ? -1 : 1;
const totalCents = Math.round(Math.abs(transaction.amount) * 100);
const amount = centsToDecimalString(totalCents * amountSign);
const isSettled = paymentMethod === "Cartão de crédito" ? null : false;
const record: TransactionInsert = {
name: transaction.name,
purchaseDate,
period,
transactionType,
amount,
condition,
paymentMethod,
payerId,
accountId,
cardId,
categoryId,
note: null,
installmentCount: null,
recurrenceCount: null,
currentInstallment: null,
isSettled,
isDivided: false,
dueDate: null,
boletoPaymentDate: null,
userId: user.id,
seriesId: null,
};
allRecords.push(record);
notificationData.push({
payerId,
name: transaction.name,
amount,
transactionType,
paymentMethod,
condition,
purchaseDate,
period,
note: null,
});
}
if (!allRecords.length) {
throw new Error("Não foi possível criar os lançamentos solicitados.");
}
await db.transaction(async (tx: typeof db) => {
await tx.insert(transactions).values(allRecords);
});
const notificationEntries = buildEntriesByPayer(notificationData);
if (notificationEntries.size > 0) {
await sendPayerAutoEmails({
userLabel: resolveUserLabel(user),
action: "created",
entriesByPagador: notificationEntries,
});
}
revalidate(user.id);
const count = allRecords.length;
return {
success: true,
message: `${count} ${
count === 1 ? "lançamento criado" : "lançamentos criados"
} com sucesso.`,
};
} catch (error) {
return handleActionError(error);
}
}
export async function deleteMultipleTransactionsAction(
input: DeleteMultipleInput,
): Promise<ActionResult> {
try {
const user = await getUser();
const data = deleteMultipleSchema.parse(input);
const existing = await db.query.transactions.findMany({
columns: {
id: true,
name: true,
payerId: true,
amount: true,
transactionType: true,
paymentMethod: true,
condition: true,
purchaseDate: true,
period: true,
note: true,
},
where: and(
inArray(transactions.id, data.ids),
eq(transactions.userId, user.id),
),
});
if (existing.length === 0) {
return { success: false, error: "Nenhum lançamento encontrado." };
}
await db
.delete(transactions)
.where(
and(
inArray(transactions.id, data.ids),
eq(transactions.userId, user.id),
),
);
const notificationData = existing
.filter(
(
item: (typeof existing)[number],
): item is typeof item & {
payerId: NonNullable<typeof item.payerId>;
} => Boolean(item.payerId),
)
.map((item: (typeof existing)[number]) => ({
payerId: item.payerId,
name: item.name ?? null,
amount: item.amount ?? null,
transactionType: item.transactionType ?? null,
paymentMethod: item.paymentMethod ?? null,
condition: item.condition ?? null,
purchaseDate: item.purchaseDate ?? null,
period: item.period ?? null,
note: item.note ?? null,
}));
if (notificationData.length > 0) {
const notificationEntries = buildEntriesByPayer(notificationData);
await sendPayerAutoEmails({
userLabel: resolveUserLabel(user),
action: "deleted",
entriesByPagador: notificationEntries,
});
}
revalidate(user.id);
const count = existing.length;
return {
success: true,
message: `${count} ${
count === 1 ? "lançamento removido" : "lançamentos removidos"
} com sucesso.`,
};
} catch (error) {
return handleActionError(error);
}
}