mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-09 19:01:47 +00:00
refactor: pagina transações e modulariza ações
This commit is contained in:
624
src/features/transactions/actions/bulk-actions.ts
Normal file
624
src/features/transactions/actions/bulk-actions.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user