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);
|
||||
}
|
||||
}
|
||||
758
src/features/transactions/actions/core.ts
Normal file
758
src/features/transactions/actions/core.ts
Normal file
@@ -0,0 +1,758 @@
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
cards,
|
||||
categories,
|
||||
financialAccounts,
|
||||
payers,
|
||||
type transactions,
|
||||
} from "@/db/schema";
|
||||
import {
|
||||
PAYMENT_METHODS,
|
||||
TRANSACTION_CONDITIONS,
|
||||
TRANSACTION_TYPES,
|
||||
} from "@/features/transactions/constants";
|
||||
import {
|
||||
INITIAL_BALANCE_CONDITION,
|
||||
INITIAL_BALANCE_NOTE,
|
||||
INITIAL_BALANCE_PAYMENT_METHOD,
|
||||
INITIAL_BALANCE_TRANSACTION_TYPE,
|
||||
} from "@/shared/lib/accounts/constants";
|
||||
import { revalidateForEntity } from "@/shared/lib/actions/helpers";
|
||||
import { db } from "@/shared/lib/db";
|
||||
import { noteSchema, uuidSchema } from "@/shared/lib/schemas/common";
|
||||
import { addMonthsToDate, parseLocalDateString } from "@/shared/utils/date";
|
||||
import { addMonthsToPeriod } from "@/shared/utils/period";
|
||||
|
||||
// ============================================================================
|
||||
// Authorization Validation Functions
|
||||
// ============================================================================
|
||||
|
||||
export async function validatePagadorOwnership(
|
||||
userId: string,
|
||||
payerId: string | null | undefined,
|
||||
): Promise<boolean> {
|
||||
if (!payerId) return true;
|
||||
|
||||
const pagador = await db.query.payers.findFirst({
|
||||
where: and(eq(payers.id, payerId), eq(payers.userId, userId)),
|
||||
});
|
||||
|
||||
return !!pagador;
|
||||
}
|
||||
|
||||
const normalizeIds = (ids: Array<string | null | undefined>) => [
|
||||
...new Set(ids.filter((id): id is string => Boolean(id))),
|
||||
];
|
||||
|
||||
export async function fetchOwnedPayerIds(
|
||||
userId: string,
|
||||
payerIds: Array<string | null | undefined>,
|
||||
): Promise<Set<string>> {
|
||||
const ids = normalizeIds(payerIds);
|
||||
if (ids.length === 0) {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select({ id: payers.id })
|
||||
.from(payers)
|
||||
.where(and(eq(payers.userId, userId), inArray(payers.id, ids)));
|
||||
|
||||
return new Set(rows.map((row) => row.id));
|
||||
}
|
||||
|
||||
export async function validateCategoriaOwnership(
|
||||
userId: string,
|
||||
categoryId: string | null | undefined,
|
||||
): Promise<boolean> {
|
||||
if (!categoryId) return true;
|
||||
|
||||
const categoria = await db.query.categories.findFirst({
|
||||
where: and(eq(categories.id, categoryId), eq(categories.userId, userId)),
|
||||
});
|
||||
|
||||
return !!categoria;
|
||||
}
|
||||
|
||||
export async function fetchOwnedCategoryIds(
|
||||
userId: string,
|
||||
categoryIds: Array<string | null | undefined>,
|
||||
): Promise<Set<string>> {
|
||||
const ids = normalizeIds(categoryIds);
|
||||
if (ids.length === 0) {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select({ id: categories.id })
|
||||
.from(categories)
|
||||
.where(and(eq(categories.userId, userId), inArray(categories.id, ids)));
|
||||
|
||||
return new Set(rows.map((row) => row.id));
|
||||
}
|
||||
|
||||
export async function validateContaOwnership(
|
||||
userId: string,
|
||||
accountId: string | null | undefined,
|
||||
): Promise<boolean> {
|
||||
if (!accountId) return true;
|
||||
|
||||
const conta = await db.query.financialAccounts.findFirst({
|
||||
where: and(
|
||||
eq(financialAccounts.id, accountId),
|
||||
eq(financialAccounts.userId, userId),
|
||||
),
|
||||
});
|
||||
|
||||
return !!conta;
|
||||
}
|
||||
|
||||
export async function fetchOwnedAccountIds(
|
||||
userId: string,
|
||||
accountIds: Array<string | null | undefined>,
|
||||
): Promise<Set<string>> {
|
||||
const ids = normalizeIds(accountIds);
|
||||
if (ids.length === 0) {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select({ id: financialAccounts.id })
|
||||
.from(financialAccounts)
|
||||
.where(
|
||||
and(
|
||||
eq(financialAccounts.userId, userId),
|
||||
inArray(financialAccounts.id, ids),
|
||||
),
|
||||
);
|
||||
|
||||
return new Set(rows.map((row) => row.id));
|
||||
}
|
||||
|
||||
export async function validateCartaoOwnership(
|
||||
userId: string,
|
||||
cardId: string | null | undefined,
|
||||
): Promise<boolean> {
|
||||
if (!cardId) return true;
|
||||
|
||||
const cartao = await db.query.cards.findFirst({
|
||||
where: and(eq(cards.id, cardId), eq(cards.userId, userId)),
|
||||
});
|
||||
|
||||
return !!cartao;
|
||||
}
|
||||
|
||||
export async function fetchOwnedCardIds(
|
||||
userId: string,
|
||||
cardIds: Array<string | null | undefined>,
|
||||
): Promise<Set<string>> {
|
||||
const ids = normalizeIds(cardIds);
|
||||
if (ids.length === 0) {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select({ id: cards.id })
|
||||
.from(cards)
|
||||
.where(and(eq(cards.userId, userId), inArray(cards.id, ids)));
|
||||
|
||||
return new Set(rows.map((row) => row.id));
|
||||
}
|
||||
|
||||
export async function validateAllOwnership(
|
||||
userId: string,
|
||||
fields: {
|
||||
payerId?: string | null;
|
||||
secondaryPayerId?: string | null;
|
||||
categoryId?: string | null;
|
||||
accountId?: string | null;
|
||||
cardId?: string | null;
|
||||
},
|
||||
): Promise<string | null> {
|
||||
const [ownedPayerIds, ownedCategoryIds, ownedAccountIds, ownedCardIds] =
|
||||
await Promise.all([
|
||||
fetchOwnedPayerIds(userId, [fields.payerId, fields.secondaryPayerId]),
|
||||
fetchOwnedCategoryIds(userId, [fields.categoryId]),
|
||||
fetchOwnedAccountIds(userId, [fields.accountId]),
|
||||
fetchOwnedCardIds(userId, [fields.cardId]),
|
||||
]);
|
||||
|
||||
const checks = [
|
||||
!fields.payerId || ownedPayerIds.has(fields.payerId),
|
||||
!fields.secondaryPayerId || ownedPayerIds.has(fields.secondaryPayerId),
|
||||
!fields.categoryId || ownedCategoryIds.has(fields.categoryId),
|
||||
!fields.accountId || ownedAccountIds.has(fields.accountId),
|
||||
!fields.cardId || ownedCardIds.has(fields.cardId),
|
||||
];
|
||||
|
||||
const errors = [
|
||||
"Pagador não encontrado ou sem permissão.",
|
||||
"Pagador secundário não encontrado ou sem permissão.",
|
||||
"Categoria não encontrada.",
|
||||
"Conta não encontrada.",
|
||||
"Cartão não encontrado.",
|
||||
];
|
||||
|
||||
for (let i = 0; i < checks.length; i++) {
|
||||
if (!checks[i]) return errors[i];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Utility Functions
|
||||
// ============================================================================
|
||||
|
||||
export const resolvePeriod = (purchaseDate: string, period?: string | null) => {
|
||||
if (period && /^\d{4}-\d{2}$/.test(period)) {
|
||||
return period;
|
||||
}
|
||||
|
||||
const date = parseLocalDateString(purchaseDate);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
throw new Error("Data da transação inválida.");
|
||||
}
|
||||
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
return `${year}-${month}`;
|
||||
};
|
||||
|
||||
export const isValidDateInput = (value: string) =>
|
||||
!Number.isNaN(parseLocalDateString(value).getTime());
|
||||
|
||||
export const baseFields = z.object({
|
||||
purchaseDate: z
|
||||
.string({ message: "Informe a data da transação." })
|
||||
.trim()
|
||||
.refine((value) => isValidDateInput(value), {
|
||||
message: "Data da transação inválida.",
|
||||
}),
|
||||
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()
|
||||
.min(1, "Informe o estabelecimento."),
|
||||
transactionType: z
|
||||
.enum(TRANSACTION_TYPES, {
|
||||
message: "Selecione um tipo de transação válido.",
|
||||
})
|
||||
.default(TRANSACTION_TYPES[0]),
|
||||
amount: z.coerce
|
||||
.number({ message: "Informe o valor da transação." })
|
||||
.min(0, "Informe um valor maior ou igual a zero."),
|
||||
condition: z.enum(TRANSACTION_CONDITIONS, {
|
||||
message: "Selecione uma condição válida.",
|
||||
}),
|
||||
paymentMethod: z.enum(PAYMENT_METHODS, {
|
||||
message: "Selecione uma forma de pagamento válida.",
|
||||
}),
|
||||
payerId: uuidSchema("Payer").nullable().optional(),
|
||||
secondaryPayerId: uuidSchema("Payer secundário").optional(),
|
||||
isSplit: z.boolean().optional().default(false),
|
||||
primarySplitAmount: z.coerce.number().min(0).optional(),
|
||||
secondarySplitAmount: z.coerce.number().min(0).optional(),
|
||||
accountId: uuidSchema("FinancialAccount").nullable().optional(),
|
||||
cardId: uuidSchema("Cartão").nullable().optional(),
|
||||
categoryId: uuidSchema("Category").nullable().optional(),
|
||||
note: noteSchema,
|
||||
installmentCount: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.min(1, "Selecione uma quantidade válida.")
|
||||
.max(60, "Selecione uma quantidade válida.")
|
||||
.optional(),
|
||||
recurrenceCount: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.min(1, "Selecione uma recorrência válida.")
|
||||
.max(60, "Selecione uma recorrência válida.")
|
||||
.optional(),
|
||||
dueDate: z
|
||||
.string()
|
||||
.trim()
|
||||
.refine((value) => !value || isValidDateInput(value), {
|
||||
message: "Informe uma data de vencimento válida.",
|
||||
})
|
||||
.optional(),
|
||||
boletoPaymentDate: z
|
||||
.string()
|
||||
.trim()
|
||||
.refine((value) => !value || isValidDateInput(value), {
|
||||
message: "Informe uma data de pagamento válida.",
|
||||
})
|
||||
.optional(),
|
||||
isSettled: z.boolean().nullable().optional(),
|
||||
});
|
||||
|
||||
const refineLancamento = (
|
||||
data: z.infer<typeof baseFields> & { id?: string },
|
||||
ctx: z.RefinementCtx,
|
||||
) => {
|
||||
if (!data.categoryId) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["categoryId"],
|
||||
message: "Selecione uma categoria.",
|
||||
});
|
||||
}
|
||||
|
||||
if (data.paymentMethod === "Cartão de crédito") {
|
||||
if (!data.cardId) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["cardId"],
|
||||
message: "Selecione o cartão.",
|
||||
});
|
||||
}
|
||||
} else if (!data.accountId) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["accountId"],
|
||||
message: "Selecione a conta.",
|
||||
});
|
||||
}
|
||||
|
||||
if (data.condition === "Recorrente") {
|
||||
if (!data.recurrenceCount) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["recurrenceCount"],
|
||||
message: "Informe por quantos meses a recorrência acontecerá.",
|
||||
});
|
||||
} else if (data.recurrenceCount < 2) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["recurrenceCount"],
|
||||
message: "A recorrência deve ter ao menos dois meses.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (data.condition === "Parcelado") {
|
||||
if (!data.installmentCount) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["installmentCount"],
|
||||
message: "Informe a quantidade de parcelas.",
|
||||
});
|
||||
} else if (data.installmentCount < 2) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["installmentCount"],
|
||||
message: "Selecione pelo menos duas parcelas.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (data.isSplit) {
|
||||
if (!data.payerId) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["payerId"],
|
||||
message: "Selecione o pagador principal para dividir o lançamento.",
|
||||
});
|
||||
}
|
||||
|
||||
if (!data.secondaryPayerId) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["secondaryPayerId"],
|
||||
message: "Selecione o pagador secundário para dividir o lançamento.",
|
||||
});
|
||||
} else if (data.payerId && data.secondaryPayerId === data.payerId) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["secondaryPayerId"],
|
||||
message: "Escolha um pagador diferente para dividir o lançamento.",
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
data.primarySplitAmount !== undefined &&
|
||||
data.secondarySplitAmount !== undefined
|
||||
) {
|
||||
const sum = data.primarySplitAmount + data.secondarySplitAmount;
|
||||
const total = Math.abs(data.amount);
|
||||
if (Math.abs(sum - total) > 0.01) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["primarySplitAmount"],
|
||||
message: "A soma das divisões deve ser igual ao valor total.",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const createSchema = baseFields.superRefine(refineLancamento);
|
||||
export const updateSchema = baseFields
|
||||
.extend({
|
||||
id: uuidSchema("Lançamento"),
|
||||
})
|
||||
.superRefine(refineLancamento);
|
||||
|
||||
export const deleteSchema = z.object({
|
||||
id: uuidSchema("Lançamento"),
|
||||
});
|
||||
|
||||
export const toggleSettlementSchema = z.object({
|
||||
id: uuidSchema("Lançamento"),
|
||||
value: z.boolean({
|
||||
message: "Informe o status de pagamento.",
|
||||
}),
|
||||
});
|
||||
|
||||
export type BaseInput = z.infer<typeof baseFields>;
|
||||
export type CreateInput = z.infer<typeof createSchema>;
|
||||
export type UpdateInput = z.infer<typeof updateSchema>;
|
||||
export type DeleteInput = z.infer<typeof deleteSchema>;
|
||||
export type ToggleSettlementInput = z.infer<typeof toggleSettlementSchema>;
|
||||
|
||||
export const revalidate = (userId: string) =>
|
||||
revalidateForEntity("transactions", userId);
|
||||
|
||||
export const resolveUserLabel = (user: {
|
||||
name?: string | null;
|
||||
email?: string | null;
|
||||
}) => {
|
||||
if (user?.name && user.name.trim().length > 0) {
|
||||
return user.name;
|
||||
}
|
||||
if (user?.email && user.email.trim().length > 0) {
|
||||
return user.email;
|
||||
}
|
||||
return "OpenMonetis";
|
||||
};
|
||||
|
||||
type InitialCandidate = {
|
||||
note: string | null;
|
||||
transactionType: string | null;
|
||||
condition: string | null;
|
||||
paymentMethod: string | null;
|
||||
};
|
||||
|
||||
export const isInitialBalanceLancamento = (record?: InitialCandidate | null) =>
|
||||
!!record &&
|
||||
record.note === INITIAL_BALANCE_NOTE &&
|
||||
record.transactionType === INITIAL_BALANCE_TRANSACTION_TYPE &&
|
||||
record.condition === INITIAL_BALANCE_CONDITION &&
|
||||
record.paymentMethod === INITIAL_BALANCE_PAYMENT_METHOD;
|
||||
|
||||
export const centsToDecimalString = (value: number) => {
|
||||
const decimal = value / 100;
|
||||
const formatted = decimal.toFixed(2);
|
||||
return Object.is(decimal, -0) ? "0.00" : formatted;
|
||||
};
|
||||
|
||||
const splitAmount = (totalCents: number, parts: number) => {
|
||||
if (parts <= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const base = Math.trunc(totalCents / parts);
|
||||
const remainder = totalCents % parts;
|
||||
|
||||
return Array.from(
|
||||
{ length: parts },
|
||||
(_, index) => base + (index < remainder ? 1 : 0),
|
||||
);
|
||||
};
|
||||
|
||||
export type Share = {
|
||||
payerId: string | null;
|
||||
amountCents: number;
|
||||
};
|
||||
|
||||
export const buildShares = ({
|
||||
totalCents,
|
||||
payerId,
|
||||
isSplit,
|
||||
secondaryPayerId,
|
||||
primarySplitAmountCents,
|
||||
secondarySplitAmountCents,
|
||||
}: {
|
||||
totalCents: number;
|
||||
payerId: string | null;
|
||||
isSplit: boolean;
|
||||
secondaryPayerId?: string;
|
||||
primarySplitAmountCents?: number;
|
||||
secondarySplitAmountCents?: number;
|
||||
}): Share[] => {
|
||||
if (isSplit) {
|
||||
if (!payerId || !secondaryPayerId) {
|
||||
throw new Error("Configuração de divisão inválida para o lançamento.");
|
||||
}
|
||||
|
||||
if (
|
||||
primarySplitAmountCents !== undefined &&
|
||||
secondarySplitAmountCents !== undefined
|
||||
) {
|
||||
return [
|
||||
{ payerId, amountCents: primarySplitAmountCents },
|
||||
{
|
||||
payerId: secondaryPayerId,
|
||||
amountCents: secondarySplitAmountCents,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const [primaryAmount, secondaryAmount] = splitAmount(totalCents, 2);
|
||||
return [
|
||||
{ payerId, amountCents: primaryAmount },
|
||||
{ payerId: secondaryPayerId, amountCents: secondaryAmount },
|
||||
];
|
||||
}
|
||||
|
||||
return [{ payerId, amountCents: totalCents }];
|
||||
};
|
||||
|
||||
type BuildTransactionRecordsParams = {
|
||||
data: BaseInput;
|
||||
userId: string;
|
||||
period: string;
|
||||
purchaseDate: Date;
|
||||
dueDate: Date | null;
|
||||
boletoPaymentDate: Date | null;
|
||||
shares: Share[];
|
||||
amountSign: 1 | -1;
|
||||
shouldNullifySettled: boolean;
|
||||
seriesId: string | null;
|
||||
};
|
||||
|
||||
export type TransactionInsert = typeof transactions.$inferInsert;
|
||||
|
||||
export const buildLancamentoRecords = ({
|
||||
data,
|
||||
userId,
|
||||
period,
|
||||
purchaseDate,
|
||||
dueDate,
|
||||
boletoPaymentDate,
|
||||
shares,
|
||||
amountSign,
|
||||
shouldNullifySettled,
|
||||
seriesId,
|
||||
}: BuildTransactionRecordsParams): TransactionInsert[] => {
|
||||
const records: TransactionInsert[] = [];
|
||||
|
||||
const basePayload = {
|
||||
name: data.name,
|
||||
transactionType: data.transactionType,
|
||||
condition: data.condition,
|
||||
paymentMethod: data.paymentMethod,
|
||||
note: data.note ?? null,
|
||||
accountId: data.accountId ?? null,
|
||||
cardId: data.cardId ?? null,
|
||||
categoryId: data.categoryId ?? null,
|
||||
recurrenceCount: null as number | null,
|
||||
installmentCount: null as number | null,
|
||||
currentInstallment: null as number | null,
|
||||
isDivided: data.isSplit ?? false,
|
||||
userId,
|
||||
seriesId,
|
||||
};
|
||||
|
||||
const resolveSettledValue = (cycleIndex: number) => {
|
||||
if (shouldNullifySettled) {
|
||||
return null;
|
||||
}
|
||||
const initialSettled = data.isSettled ?? false;
|
||||
if (data.condition === "Parcelado" || data.condition === "Recorrente") {
|
||||
return cycleIndex === 0 ? initialSettled : false;
|
||||
}
|
||||
return initialSettled;
|
||||
};
|
||||
|
||||
if (data.condition === "Parcelado") {
|
||||
const installmentTotal = data.installmentCount ?? 0;
|
||||
const amountsByShare = shares.map((share) =>
|
||||
splitAmount(share.amountCents, installmentTotal),
|
||||
);
|
||||
|
||||
for (
|
||||
let installment = 0;
|
||||
installment < installmentTotal;
|
||||
installment += 1
|
||||
) {
|
||||
const installmentPeriod = addMonthsToPeriod(period, installment);
|
||||
const installmentDueDate = dueDate
|
||||
? addMonthsToDate(dueDate, installment)
|
||||
: null;
|
||||
|
||||
shares.forEach((share, shareIndex) => {
|
||||
const amountCents = amountsByShare[shareIndex]?.[installment] ?? 0;
|
||||
const settled = resolveSettledValue(installment);
|
||||
records.push({
|
||||
...basePayload,
|
||||
amount: centsToDecimalString(amountCents * amountSign),
|
||||
payerId: share.payerId,
|
||||
purchaseDate,
|
||||
period: installmentPeriod,
|
||||
isSettled: settled,
|
||||
installmentCount: installmentTotal,
|
||||
currentInstallment: installment + 1,
|
||||
recurrenceCount: null,
|
||||
dueDate: installmentDueDate,
|
||||
boletoPaymentDate:
|
||||
data.paymentMethod === "Boleto" && settled
|
||||
? boletoPaymentDate
|
||||
: null,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return records;
|
||||
}
|
||||
|
||||
if (data.condition === "Recorrente") {
|
||||
const recurrenceTotal = data.recurrenceCount ?? 0;
|
||||
|
||||
for (let index = 0; index < recurrenceTotal; index += 1) {
|
||||
const recurrencePeriod = addMonthsToPeriod(period, index);
|
||||
const recurrencePurchaseDate = addMonthsToDate(purchaseDate, index);
|
||||
const recurrenceDueDate = dueDate
|
||||
? addMonthsToDate(dueDate, index)
|
||||
: null;
|
||||
|
||||
shares.forEach((share) => {
|
||||
const settled = resolveSettledValue(index);
|
||||
records.push({
|
||||
...basePayload,
|
||||
amount: centsToDecimalString(share.amountCents * amountSign),
|
||||
payerId: share.payerId,
|
||||
purchaseDate: recurrencePurchaseDate,
|
||||
period: recurrencePeriod,
|
||||
isSettled: settled,
|
||||
recurrenceCount: recurrenceTotal,
|
||||
dueDate: recurrenceDueDate,
|
||||
boletoPaymentDate:
|
||||
data.paymentMethod === "Boleto" && settled
|
||||
? boletoPaymentDate
|
||||
: null,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return records;
|
||||
}
|
||||
|
||||
shares.forEach((share) => {
|
||||
const settled = resolveSettledValue(0);
|
||||
records.push({
|
||||
...basePayload,
|
||||
amount: centsToDecimalString(share.amountCents * amountSign),
|
||||
payerId: share.payerId,
|
||||
purchaseDate,
|
||||
period,
|
||||
isSettled: settled,
|
||||
dueDate,
|
||||
boletoPaymentDate:
|
||||
data.paymentMethod === "Boleto" && settled ? boletoPaymentDate : null,
|
||||
});
|
||||
});
|
||||
|
||||
return records;
|
||||
};
|
||||
|
||||
export const deleteBulkSchema = z.object({
|
||||
id: uuidSchema("Lançamento"),
|
||||
scope: z.enum(["current", "future", "all"], {
|
||||
message: "Escopo de ação inválido.",
|
||||
}),
|
||||
});
|
||||
|
||||
export type DeleteBulkInput = z.infer<typeof deleteBulkSchema>;
|
||||
|
||||
export const updateBulkSchema = z.object({
|
||||
id: uuidSchema("Lançamento"),
|
||||
scope: z.enum(["current", "future", "all"], {
|
||||
message: "Escopo de ação inválido.",
|
||||
}),
|
||||
name: z
|
||||
.string({ message: "Informe o estabelecimento." })
|
||||
.trim()
|
||||
.min(1, "Informe o estabelecimento."),
|
||||
categoryId: uuidSchema("Category").nullable().optional(),
|
||||
note: noteSchema,
|
||||
payerId: uuidSchema("Payer").nullable().optional(),
|
||||
accountId: uuidSchema("FinancialAccount").nullable().optional(),
|
||||
cardId: uuidSchema("Cartão").nullable().optional(),
|
||||
amount: z.coerce
|
||||
.number({ message: "Informe o valor da transação." })
|
||||
.min(0, "Informe um valor maior ou igual a zero.")
|
||||
.optional(),
|
||||
dueDate: z
|
||||
.string()
|
||||
.trim()
|
||||
.refine((value) => !value || isValidDateInput(value), {
|
||||
message: "Informe uma data de vencimento válida.",
|
||||
})
|
||||
.optional()
|
||||
.nullable(),
|
||||
boletoPaymentDate: z
|
||||
.string()
|
||||
.trim()
|
||||
.refine((value) => !value || isValidDateInput(value), {
|
||||
message: "Informe uma data de pagamento válida.",
|
||||
})
|
||||
.optional()
|
||||
.nullable(),
|
||||
});
|
||||
|
||||
export type UpdateBulkInput = z.infer<typeof updateBulkSchema>;
|
||||
|
||||
export const massAddTransactionSchema = z.object({
|
||||
purchaseDate: z
|
||||
.string({ message: "Informe a data da transação." })
|
||||
.trim()
|
||||
.refine((value) => isValidDateInput(value), {
|
||||
message: "Data da transação inválida.",
|
||||
}),
|
||||
name: z
|
||||
.string({ message: "Informe o estabelecimento." })
|
||||
.trim()
|
||||
.min(1, "Informe o estabelecimento."),
|
||||
amount: z.coerce
|
||||
.number({ message: "Informe o valor da transação." })
|
||||
.min(0, "Informe um valor maior ou igual a zero."),
|
||||
categoryId: uuidSchema("Category").nullable().optional(),
|
||||
payerId: uuidSchema("Payer").nullable().optional(),
|
||||
});
|
||||
|
||||
export const massAddSchema = z.object({
|
||||
fixedFields: z.object({
|
||||
transactionType: z.enum(TRANSACTION_TYPES).optional(),
|
||||
paymentMethod: z.enum(PAYMENT_METHODS).optional(),
|
||||
condition: z.enum(TRANSACTION_CONDITIONS).optional(),
|
||||
period: z
|
||||
.string()
|
||||
.trim()
|
||||
.regex(/^(\d{4})-(\d{2})$/, {
|
||||
message: "Selecione um período válido.",
|
||||
})
|
||||
.optional(),
|
||||
accountId: uuidSchema("FinancialAccount").nullable().optional(),
|
||||
cardId: uuidSchema("Cartão").nullable().optional(),
|
||||
}),
|
||||
transactions: z
|
||||
.array(massAddTransactionSchema)
|
||||
.min(1, "Adicione pelo menos uma transação."),
|
||||
});
|
||||
|
||||
export type MassAddInput = z.infer<typeof massAddSchema>;
|
||||
|
||||
export const deleteMultipleSchema = z.object({
|
||||
ids: z
|
||||
.array(uuidSchema("Lançamento"))
|
||||
.min(1, "Selecione pelo menos um lançamento."),
|
||||
});
|
||||
|
||||
export type DeleteMultipleInput = z.infer<typeof deleteMultipleSchema>;
|
||||
81
src/features/transactions/actions/export-actions.ts
Normal file
81
src/features/transactions/actions/export-actions.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
"use server";
|
||||
|
||||
import { z } from "zod";
|
||||
import { fetchAccountLancamentos } from "@/features/accounts/statement-queries";
|
||||
import type { TransactionsExportContext } from "@/features/transactions/export-types";
|
||||
import {
|
||||
buildSluggedFilters,
|
||||
buildSlugMaps,
|
||||
buildTransactionWhere,
|
||||
mapTransactionsData,
|
||||
} from "@/features/transactions/page-helpers";
|
||||
import {
|
||||
fetchTransactionFilterSources,
|
||||
fetchTransactions,
|
||||
} from "@/features/transactions/queries";
|
||||
import {
|
||||
type ActionResult,
|
||||
handleActionError,
|
||||
} from "@/shared/lib/actions/helpers";
|
||||
import { getUserId } from "@/shared/lib/auth/server";
|
||||
|
||||
const exportTransactionsSchema: z.ZodType<TransactionsExportContext> = z.object(
|
||||
{
|
||||
source: z.enum(["transactions", "account-statement"]),
|
||||
period: z.string().regex(/^\d{4}-\d{2}$/),
|
||||
filters: z.object({
|
||||
transactionFilter: z.string().nullable(),
|
||||
conditionFilter: z.string().nullable(),
|
||||
paymentFilter: z.string().nullable(),
|
||||
payerFilter: z.string().nullable(),
|
||||
categoryFilter: z.string().nullable(),
|
||||
accountCardFilter: z.string().nullable(),
|
||||
searchFilter: z.string().nullable(),
|
||||
}),
|
||||
accountId: z.string().min(1).nullable().optional(),
|
||||
cardId: z.string().min(1).nullable().optional(),
|
||||
payerId: z.string().min(1).nullable().optional(),
|
||||
settledOnly: z.boolean().optional(),
|
||||
},
|
||||
);
|
||||
|
||||
export async function exportTransactionsDataAction(
|
||||
input: TransactionsExportContext,
|
||||
): Promise<
|
||||
ActionResult<{ transactions: ReturnType<typeof mapTransactionsData> }>
|
||||
> {
|
||||
try {
|
||||
const userId = await getUserId();
|
||||
const validated = exportTransactionsSchema.parse(input);
|
||||
const filterSources = await fetchTransactionFilterSources(userId);
|
||||
const sluggedFilters = buildSluggedFilters(filterSources);
|
||||
const slugMaps = buildSlugMaps(sluggedFilters);
|
||||
|
||||
const filters = buildTransactionWhere({
|
||||
userId,
|
||||
period: validated.period,
|
||||
filters: validated.filters,
|
||||
slugMaps,
|
||||
accountId: validated.accountId ?? undefined,
|
||||
cardId: validated.cardId ?? undefined,
|
||||
payerId: validated.payerId ?? undefined,
|
||||
});
|
||||
|
||||
const rows =
|
||||
validated.source === "account-statement"
|
||||
? await fetchAccountLancamentos(filters, validated.settledOnly ?? true)
|
||||
: await fetchTransactions(filters);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Dados carregados para exportação.",
|
||||
data: {
|
||||
transactions: mapTransactionsData(rows),
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return handleActionError(error) as ActionResult<{
|
||||
transactions: ReturnType<typeof mapTransactionsData>;
|
||||
}>;
|
||||
}
|
||||
}
|
||||
422
src/features/transactions/actions/single-actions.ts
Normal file
422
src/features/transactions/actions/single-actions.ts
Normal file
@@ -0,0 +1,422 @@
|
||||
"use server";
|
||||
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { financialAccounts, 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 {
|
||||
buildEntriesByPayer,
|
||||
sendPayerAutoEmails,
|
||||
} from "@/shared/lib/payers/notifications";
|
||||
import type { ActionResult } from "@/shared/lib/types/actions";
|
||||
import { formatDecimalForDbRequired } from "@/shared/utils/currency";
|
||||
import {
|
||||
getBusinessTodayDate,
|
||||
parseLocalDateString,
|
||||
} from "@/shared/utils/date";
|
||||
import {
|
||||
buildLancamentoRecords,
|
||||
buildShares,
|
||||
type CreateInput,
|
||||
centsToDecimalString,
|
||||
createSchema,
|
||||
type DeleteInput,
|
||||
deleteSchema,
|
||||
isInitialBalanceLancamento,
|
||||
resolvePeriod,
|
||||
resolveUserLabel,
|
||||
revalidate,
|
||||
type ToggleSettlementInput,
|
||||
toggleSettlementSchema,
|
||||
type UpdateInput,
|
||||
updateSchema,
|
||||
validateAllOwnership,
|
||||
} from "./core";
|
||||
|
||||
export async function createTransactionAction(
|
||||
input: CreateInput,
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
const data = createSchema.parse(input);
|
||||
|
||||
const ownershipError = await validateAllOwnership(user.id, {
|
||||
payerId: data.payerId,
|
||||
secondaryPayerId: data.secondaryPayerId,
|
||||
categoryId: data.categoryId,
|
||||
accountId: data.accountId,
|
||||
cardId: data.cardId,
|
||||
});
|
||||
if (ownershipError) {
|
||||
return { success: false, error: ownershipError };
|
||||
}
|
||||
|
||||
const period = resolvePeriod(data.purchaseDate, data.period);
|
||||
const purchaseDate = parseLocalDateString(data.purchaseDate);
|
||||
const dueDate = data.dueDate ? parseLocalDateString(data.dueDate) : null;
|
||||
const shouldSetBoletoPaymentDate =
|
||||
data.paymentMethod === "Boleto" && (data.isSettled ?? false);
|
||||
const boletoPaymentDate = shouldSetBoletoPaymentDate
|
||||
? data.boletoPaymentDate
|
||||
? parseLocalDateString(data.boletoPaymentDate)
|
||||
: getBusinessTodayDate()
|
||||
: null;
|
||||
|
||||
const amountSign: 1 | -1 = data.transactionType === "Despesa" ? -1 : 1;
|
||||
const totalCents = Math.round(Math.abs(data.amount) * 100);
|
||||
const shouldNullifySettled = data.paymentMethod === "Cartão de crédito";
|
||||
|
||||
const shares = buildShares({
|
||||
totalCents,
|
||||
payerId: data.payerId ?? null,
|
||||
isSplit: data.isSplit ?? false,
|
||||
secondaryPayerId: data.secondaryPayerId,
|
||||
primarySplitAmountCents: data.primarySplitAmount
|
||||
? Math.round(data.primarySplitAmount * 100)
|
||||
: undefined,
|
||||
secondarySplitAmountCents: data.secondarySplitAmount
|
||||
? Math.round(data.secondarySplitAmount * 100)
|
||||
: undefined,
|
||||
});
|
||||
|
||||
const isSeriesLancamento =
|
||||
data.condition === "Parcelado" || data.condition === "Recorrente";
|
||||
const seriesId = isSeriesLancamento ? randomUUID() : null;
|
||||
|
||||
const records = buildLancamentoRecords({
|
||||
data,
|
||||
userId: user.id,
|
||||
period,
|
||||
purchaseDate,
|
||||
dueDate,
|
||||
shares,
|
||||
amountSign,
|
||||
shouldNullifySettled,
|
||||
boletoPaymentDate,
|
||||
seriesId,
|
||||
});
|
||||
|
||||
if (!records.length) {
|
||||
throw new Error("Não foi possível criar os lançamentos solicitados.");
|
||||
}
|
||||
|
||||
await db.insert(transactions).values(records);
|
||||
|
||||
const notificationEntries = buildEntriesByPayer(
|
||||
records.map((record) => ({
|
||||
payerId: record.payerId ?? null,
|
||||
name: record.name ?? null,
|
||||
amount: record.amount ?? null,
|
||||
transactionType: record.transactionType ?? null,
|
||||
paymentMethod: record.paymentMethod ?? null,
|
||||
condition: record.condition ?? null,
|
||||
purchaseDate: record.purchaseDate ?? null,
|
||||
period: record.period ?? null,
|
||||
note: record.note ?? null,
|
||||
})),
|
||||
);
|
||||
|
||||
if (notificationEntries.size > 0) {
|
||||
await sendPayerAutoEmails({
|
||||
userLabel: resolveUserLabel(user),
|
||||
action: "created",
|
||||
entriesByPagador: notificationEntries,
|
||||
});
|
||||
}
|
||||
|
||||
revalidate(user.id);
|
||||
|
||||
return { success: true, message: "Lançamento criado com sucesso." };
|
||||
} catch (error) {
|
||||
return handleActionError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateTransactionAction(
|
||||
input: UpdateInput,
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
const data = updateSchema.parse(input);
|
||||
|
||||
const ownershipError = await validateAllOwnership(user.id, {
|
||||
payerId: data.payerId,
|
||||
secondaryPayerId: data.secondaryPayerId,
|
||||
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,
|
||||
note: true,
|
||||
transactionType: true,
|
||||
condition: true,
|
||||
paymentMethod: true,
|
||||
accountId: true,
|
||||
categoryId: true,
|
||||
},
|
||||
where: and(
|
||||
eq(transactions.id, data.id),
|
||||
eq(transactions.userId, user.id),
|
||||
),
|
||||
with: {
|
||||
category: {
|
||||
columns: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})) as
|
||||
| {
|
||||
id: string;
|
||||
note: string | null;
|
||||
transactionType: string;
|
||||
condition: string;
|
||||
paymentMethod: string;
|
||||
accountId: string | null;
|
||||
categoryId: string | null;
|
||||
category: { name: string } | null;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
if (!existing) {
|
||||
return { success: false, error: "Lançamento não encontrado." };
|
||||
}
|
||||
|
||||
const categoriasProtegidasEdicao = ["Saldo inicial", "Pagamentos"];
|
||||
if (
|
||||
existing.category?.name &&
|
||||
categoriasProtegidasEdicao.includes(existing.category.name)
|
||||
) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Lançamentos com a categoria '${existing.category.name}' não podem ser editados.`,
|
||||
};
|
||||
}
|
||||
|
||||
const period = resolvePeriod(data.purchaseDate, data.period);
|
||||
const amountSign: 1 | -1 = data.transactionType === "Despesa" ? -1 : 1;
|
||||
const amountCents = Math.round(Math.abs(data.amount) * 100);
|
||||
const normalizedAmount = centsToDecimalString(amountCents * amountSign);
|
||||
const normalizedSettled =
|
||||
data.paymentMethod === "Cartão de crédito"
|
||||
? null
|
||||
: (data.isSettled ?? false);
|
||||
const shouldSetBoletoPaymentDate =
|
||||
data.paymentMethod === "Boleto" && Boolean(normalizedSettled);
|
||||
const boletoPaymentDateValue = shouldSetBoletoPaymentDate
|
||||
? data.boletoPaymentDate
|
||||
? parseLocalDateString(data.boletoPaymentDate)
|
||||
: getBusinessTodayDate()
|
||||
: null;
|
||||
|
||||
await db
|
||||
.update(transactions)
|
||||
.set({
|
||||
name: data.name,
|
||||
purchaseDate: parseLocalDateString(data.purchaseDate),
|
||||
transactionType: data.transactionType,
|
||||
amount: normalizedAmount,
|
||||
condition: data.condition,
|
||||
paymentMethod: data.paymentMethod,
|
||||
payerId: data.payerId ?? null,
|
||||
accountId: data.accountId ?? null,
|
||||
cardId: data.cardId ?? null,
|
||||
categoryId: data.categoryId ?? null,
|
||||
note: data.note ?? null,
|
||||
isSettled: normalizedSettled,
|
||||
installmentCount: data.installmentCount ?? null,
|
||||
recurrenceCount: data.recurrenceCount ?? null,
|
||||
dueDate: data.dueDate ? parseLocalDateString(data.dueDate) : null,
|
||||
boletoPaymentDate: boletoPaymentDateValue,
|
||||
period,
|
||||
})
|
||||
.where(
|
||||
and(eq(transactions.id, data.id), eq(transactions.userId, user.id)),
|
||||
);
|
||||
|
||||
if (isInitialBalanceLancamento(existing) && existing.accountId) {
|
||||
const updatedInitialBalance = formatDecimalForDbRequired(
|
||||
Math.abs(data.amount ?? 0),
|
||||
);
|
||||
await db
|
||||
.update(financialAccounts)
|
||||
.set({ initialBalance: updatedInitialBalance })
|
||||
.where(
|
||||
and(
|
||||
eq(financialAccounts.id, existing.accountId),
|
||||
eq(financialAccounts.userId, user.id),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
revalidate(user.id);
|
||||
|
||||
return { success: true, message: "Lançamento atualizado com sucesso." };
|
||||
} catch (error) {
|
||||
return handleActionError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteTransactionAction(
|
||||
input: DeleteInput,
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
const data = deleteSchema.parse(input);
|
||||
|
||||
const existing = (await db.query.transactions.findFirst({
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
payerId: true,
|
||||
amount: true,
|
||||
transactionType: true,
|
||||
paymentMethod: true,
|
||||
condition: true,
|
||||
purchaseDate: true,
|
||||
period: true,
|
||||
note: true,
|
||||
categoryId: true,
|
||||
},
|
||||
where: and(
|
||||
eq(transactions.id, data.id),
|
||||
eq(transactions.userId, user.id),
|
||||
),
|
||||
with: {
|
||||
category: {
|
||||
columns: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})) as
|
||||
| {
|
||||
id: string;
|
||||
name: string | null;
|
||||
payerId: string | null;
|
||||
amount: string | null;
|
||||
transactionType: string;
|
||||
paymentMethod: string;
|
||||
condition: string;
|
||||
purchaseDate: Date | null;
|
||||
period: string;
|
||||
note: string | null;
|
||||
categoryId: string | null;
|
||||
category: { name: string } | null;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
if (!existing) {
|
||||
return { success: false, error: "Lançamento não encontrado." };
|
||||
}
|
||||
|
||||
const categoriasProtegidasRemocao = ["Saldo inicial", "Pagamentos"];
|
||||
if (
|
||||
existing.category?.name &&
|
||||
categoriasProtegidasRemocao.includes(existing.category.name)
|
||||
) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Lançamentos com a categoria '${existing.category.name}' não podem ser removidos.`,
|
||||
};
|
||||
}
|
||||
|
||||
await db
|
||||
.delete(transactions)
|
||||
.where(
|
||||
and(eq(transactions.id, data.id), eq(transactions.userId, user.id)),
|
||||
);
|
||||
|
||||
if (existing.payerId) {
|
||||
const notificationEntries = buildEntriesByPayer([
|
||||
{
|
||||
payerId: existing.payerId,
|
||||
name: existing.name ?? null,
|
||||
amount: existing.amount ?? null,
|
||||
transactionType: existing.transactionType ?? null,
|
||||
paymentMethod: existing.paymentMethod ?? null,
|
||||
condition: existing.condition ?? null,
|
||||
purchaseDate: existing.purchaseDate ?? null,
|
||||
period: existing.period ?? null,
|
||||
note: existing.note ?? null,
|
||||
},
|
||||
]);
|
||||
|
||||
await sendPayerAutoEmails({
|
||||
userLabel: resolveUserLabel(user),
|
||||
action: "deleted",
|
||||
entriesByPagador: notificationEntries,
|
||||
});
|
||||
}
|
||||
|
||||
revalidate(user.id);
|
||||
|
||||
return { success: true, message: "Lançamento removido com sucesso." };
|
||||
} catch (error) {
|
||||
return handleActionError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function toggleTransactionSettlementAction(
|
||||
input: ToggleSettlementInput,
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
const data = toggleSettlementSchema.parse(input);
|
||||
|
||||
const existing = await db.query.transactions.findFirst({
|
||||
columns: { id: true, paymentMethod: 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.paymentMethod === "Cartão de crédito") {
|
||||
return {
|
||||
success: false,
|
||||
error: "Pagamentos com cartão são conciliados automaticamente.",
|
||||
};
|
||||
}
|
||||
|
||||
const isBoleto = existing.paymentMethod === "Boleto";
|
||||
const boletoPaymentDate = isBoleto
|
||||
? data.value
|
||||
? getBusinessTodayDate()
|
||||
: null
|
||||
: null;
|
||||
|
||||
await db
|
||||
.update(transactions)
|
||||
.set({
|
||||
isSettled: data.value,
|
||||
boletoPaymentDate,
|
||||
})
|
||||
.where(
|
||||
and(eq(transactions.id, data.id), eq(transactions.userId, user.id)),
|
||||
);
|
||||
|
||||
revalidate(user.id);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: data.value
|
||||
? "Lançamento marcado como pago."
|
||||
: "Pagamento desfeito com sucesso.",
|
||||
};
|
||||
} catch (error) {
|
||||
return handleActionError(error);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user