feat: endurece mutações financeiras e permite zerar conta

This commit is contained in:
Felipe Coutinho
2026-03-20 18:42:18 +00:00
parent f77c64325d
commit e4dd221709
23 changed files with 5490 additions and 2942 deletions

View File

@@ -2,12 +2,7 @@
import { and, eq } from "drizzle-orm";
import { z } from "zod";
import {
categories,
financialAccounts,
payers,
transactions,
} from "@/db/schema";
import { categories, financialAccounts, transactions } from "@/db/schema";
import {
INITIAL_BALANCE_CATEGORY_NAME,
INITIAL_BALANCE_CONDITION,
@@ -22,7 +17,7 @@ import {
} from "@/shared/lib/actions/helpers";
import { getUser } from "@/shared/lib/auth/server";
import { db } from "@/shared/lib/db";
import { PAYER_ROLE_ADMIN } from "@/shared/lib/payers/constants";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
import { noteSchema, uuidSchema } from "@/shared/lib/schemas/common";
import {
TRANSFER_CATEGORY_NAME,
@@ -54,14 +49,20 @@ const accountBaseSchema = z.object({
.trim()
.min(1, "Selecione um logo."),
initialBalance: z
.string()
.trim()
.transform((value) => (value.length === 0 ? "0" : value.replace(",", ".")))
.refine(
(value) => !Number.isNaN(Number.parseFloat(value)),
"Informe um saldo inicial válido.",
)
.transform((value) => Number.parseFloat(value)),
.union([
z.number(),
z
.string()
.trim()
.transform((value) =>
value.length === 0 ? "0" : value.replace(",", "."),
)
.refine(
(value) => !Number.isNaN(Number.parseFloat(value)),
"Informe um saldo inicial válido.",
)
.transform((value) => Number.parseFloat(value)),
]),
excludeFromBalance: z
.union([z.boolean(), z.string()])
.transform((value) => value === true || value === "true"),
@@ -93,6 +94,15 @@ export async function createAccountAction(
const normalizedInitialBalance = Math.abs(data.initialBalance);
const hasInitialBalance = normalizedInitialBalance > 0;
const adminPayerId = hasInitialBalance
? await getAdminPayerId(user.id)
: null;
if (hasInitialBalance && !adminPayerId) {
throw new Error(
"Payer com papel administrador não encontrado. Crie um pagador admin antes de definir um saldo inicial.",
);
}
await db.transaction(async (tx: typeof db) => {
const [createdAccount] = await tx
@@ -118,7 +128,7 @@ export async function createAccountAction(
return;
}
const [category, adminPagador] = await Promise.all([
const [category] = await Promise.all([
tx.query.categories.findFirst({
columns: { id: true },
where: and(
@@ -126,13 +136,6 @@ export async function createAccountAction(
eq(categories.name, INITIAL_BALANCE_CATEGORY_NAME),
),
}),
tx.query.payers.findFirst({
columns: { id: true },
where: and(
eq(payers.userId, user.id),
eq(payers.role, PAYER_ROLE_ADMIN),
),
}),
]);
if (!category) {
@@ -141,12 +144,6 @@ export async function createAccountAction(
);
}
if (!adminPagador) {
throw new Error(
"Payer com papel administrador não encontrado. Crie um pagador admin antes de definir um saldo inicial.",
);
}
const { date, period } = getTodayInfo();
await tx.insert(transactions).values({
@@ -162,15 +159,15 @@ export async function createAccountAction(
userId: user.id,
accountId: createdAccount.id,
categoryId: category.id,
payerId: adminPagador.id,
payerId: adminPayerId,
});
});
revalidateForEntity("accounts");
revalidateForEntity("accounts", user.id);
return {
success: true,
message: "FinancialAccount criada com sucesso.",
message: "Conta criada com sucesso.",
};
} catch (error) {
return handleActionError(error);
@@ -209,15 +206,15 @@ export async function updateAccountAction(
if (!updated) {
return {
success: false,
error: "FinancialAccount não encontrada.",
error: "Conta não encontrada.",
};
}
revalidateForEntity("accounts");
revalidateForEntity("accounts", user.id);
return {
success: true,
message: "FinancialAccount atualizada com sucesso.",
message: "Conta atualizada com sucesso.",
};
} catch (error) {
return handleActionError(error);
@@ -244,15 +241,15 @@ export async function deleteAccountAction(
if (!deleted) {
return {
success: false,
error: "FinancialAccount não encontrada.",
error: "Conta não encontrada.",
};
}
revalidateForEntity("accounts");
revalidateForEntity("accounts", user.id);
return {
success: true,
message: "FinancialAccount removida com sucesso.",
message: "Conta removida com sucesso.",
};
} catch (error) {
return handleActionError(error);
@@ -261,8 +258,8 @@ export async function deleteAccountAction(
// Transfer between accounts
const transferSchema = z.object({
fromAccountId: uuidSchema("FinancialAccount de origem"),
toAccountId: uuidSchema("FinancialAccount de destino"),
fromAccountId: uuidSchema("Conta de origem"),
toAccountId: uuidSchema("Conta de destino"),
amount: z
.string()
.trim()
@@ -299,6 +296,13 @@ export async function transferBetweenAccountsAction(
// Generate a unique transfer ID to link both transactions
const transferId = crypto.randomUUID();
const adminPayerId = await getAdminPayerId(user.id);
if (!adminPayerId) {
throw new Error(
"Payer administrador não encontrado. Por favor, crie um pagador admin.",
);
}
await db.transaction(async (tx: typeof db) => {
// Verify both accounts exist and belong to the user
@@ -320,21 +324,23 @@ export async function transferBetweenAccountsAction(
]);
if (!fromAccount) {
throw new Error("FinancialAccount de origem não encontrada.");
throw new Error("Conta de origem não encontrada.");
}
if (!toAccount) {
throw new Error("FinancialAccount de destino não encontrada.");
throw new Error("Conta de destino não encontrada.");
}
// Get the transfer category
const transferCategory = await tx.query.categories.findFirst({
columns: { id: true },
where: and(
eq(categories.userId, user.id),
eq(categories.name, TRANSFER_CATEGORY_NAME),
),
});
// Get the transfer category and admin payer in parallel
const [transferCategory] = await Promise.all([
tx.query.categories.findFirst({
columns: { id: true },
where: and(
eq(categories.userId, user.id),
eq(categories.name, TRANSFER_CATEGORY_NAME),
),
}),
]);
if (!transferCategory) {
throw new Error(
@@ -342,62 +348,41 @@ export async function transferBetweenAccountsAction(
);
}
// Get the admin payer
const adminPagador = await tx.query.payers.findFirst({
columns: { id: true },
where: and(
eq(payers.userId, user.id),
eq(payers.role, PAYER_ROLE_ADMIN),
),
});
if (!adminPagador) {
throw new Error(
"Payer administrador não encontrado. Por favor, crie um pagador admin.",
);
}
const transferNote = `de ${fromAccount.name} -> ${toAccount.name}`;
// Create outgoing transaction (transfer from source account)
await tx.insert(transactions).values({
const sharedFields = {
condition: TRANSFER_CONDITION,
name: TRANSFER_ESTABLISHMENT_SAIDA,
paymentMethod: TRANSFER_PAYMENT_METHOD,
note: transferNote,
amount: formatDecimalForDbRequired(-Math.abs(data.amount)),
purchaseDate: data.date,
transactionType: "Transferência",
transactionType: "Transferência" as const,
period: data.period,
isSettled: true,
userId: user.id,
accountId: fromAccount.id,
categoryId: transferCategory.id,
payerId: adminPagador.id,
payerId: adminPayerId,
transferId,
});
};
// Create incoming transaction (transfer to destination account)
await tx.insert(transactions).values({
condition: TRANSFER_CONDITION,
name: TRANSFER_ESTABLISHMENT_ENTRADA,
paymentMethod: TRANSFER_PAYMENT_METHOD,
note: transferNote,
amount: formatDecimalForDbRequired(Math.abs(data.amount)),
purchaseDate: data.date,
transactionType: "Transferência",
period: data.period,
isSettled: true,
userId: user.id,
accountId: toAccount.id,
categoryId: transferCategory.id,
payerId: adminPagador.id,
transferId,
});
// Create both transactions in a single batch insert
await tx.insert(transactions).values([
{
...sharedFields,
name: TRANSFER_ESTABLISHMENT_SAIDA,
amount: formatDecimalForDbRequired(-Math.abs(data.amount)),
accountId: fromAccount.id,
},
{
...sharedFields,
name: TRANSFER_ESTABLISHMENT_ENTRADA,
amount: formatDecimalForDbRequired(Math.abs(data.amount)),
accountId: toAccount.id,
},
]);
});
revalidateForEntity("accounts");
revalidateForEntity("transactions");
revalidateForEntity("accounts", user.id);
revalidateForEntity("transactions", user.id);
return {
success: true,

View File

@@ -33,10 +33,10 @@ import { AccountFormFields } from "./account-form-fields";
import type { Account, AccountFormValues } from "./types";
const DEFAULT_ACCOUNT_TYPES = [
"FinancialAccount Corrente",
"FinancialAccount Poupança",
"Conta Corrente",
"Conta Poupança",
"Carteira Digital",
"FinancialAccount Investimento",
"Conta Investimento",
"Pré-Pago | VR/VA",
] as const;
@@ -167,7 +167,7 @@ export function AccountDialog({
const accountId = account?.id;
if (mode === "update" && !accountId) {
const message = "FinancialAccount inválida.";
const message = "Conta inválida.";
setErrorMessage(message);
toast.error(message);
return;

View File

@@ -20,7 +20,10 @@ interface AccountFormFieldsProps {
values: AccountFormValues;
accountTypes: string[];
accountStatuses: string[];
onChange: (field: keyof AccountFormValues, value: string) => void;
onChange: <K extends keyof AccountFormValues>(
field: K,
value: AccountFormValues[K],
) => void;
showInitialBalance?: boolean;
}
@@ -112,7 +115,7 @@ export function AccountFormFields({
id="exclude-from-balance"
checked={Boolean(values.excludeFromBalance)}
onCheckedChange={(checked) =>
onChange("excludeFromBalance", checked ? "true" : "false")
onChange("excludeFromBalance", checked === true)
}
/>
<Label
@@ -129,10 +132,7 @@ export function AccountFormFields({
id="exclude-initial-balance-from-income"
checked={Boolean(values.excludeInitialBalanceFromIncome)}
onCheckedChange={(checked) =>
onChange(
"excludeInitialBalanceFromIncome",
checked ? "true" : "false",
)
onChange("excludeInitialBalanceFromIncome", checked === true)
}
/>
<Label

View File

@@ -157,7 +157,7 @@ export function TransferDialog({
</div>
<div className="flex flex-col gap-2 sm:col-span-2">
<Label htmlFor="from-account">FinancialAccount de origem</Label>
<Label htmlFor="from-account">Conta de origem</Label>
<Select value={fromAccountId} disabled>
<SelectTrigger id="from-account" className="w-full">
<SelectValue>
@@ -185,7 +185,7 @@ export function TransferDialog({
</div>
<div className="flex flex-col gap-2 sm:col-span-2">
<Label htmlFor="to-account">FinancialAccount de destino</Label>
<Label htmlFor="to-account">Conta de destino</Label>
{availableAccounts.length === 0 ? (
<div className="rounded-md border border-border bg-muted p-3 text-sm text-muted-foreground">
É necessário ter mais de uma conta cadastrada para realizar

View File

@@ -112,7 +112,7 @@ export function SignupForm({ className, ...props }: DivProps) {
},
onSuccess: () => {
setLoadingEmail(false);
toast.success("FinancialAccount criada com sucesso!");
toast.success("Conta criada com sucesso!");
router.replace("/dashboard");
},
onError: (ctx) => {

View File

@@ -1,6 +1,6 @@
"use server";
import { and, eq, ne } from "drizzle-orm";
import { and, eq } from "drizzle-orm";
import { z } from "zod";
import { budgets, categories } from "@/db/schema";
import {
@@ -52,6 +52,28 @@ type BudgetCopyRow = {
amount: unknown;
};
const BUDGET_DUPLICATE_ERROR =
"Já existe um orçamento para esta categoria no período selecionado.";
const BUDGET_UNIQUE_CONSTRAINT = "orcamentos_user_id_categoria_id_periodo_key";
const hasUniqueConstraintError = (error: unknown, constraint: string) => {
if (!error || typeof error !== "object") {
return false;
}
const candidate = error as {
code?: string;
constraint?: string;
cause?: { code?: string; constraint?: string };
};
return (
(candidate.code === "23505" && candidate.constraint === constraint) ||
(candidate.cause?.code === "23505" &&
candidate.cause.constraint === constraint)
);
};
const ensureCategory = async (userId: string, categoryId: string) => {
const category = await db.query.categories.findFirst({
columns: {
@@ -79,36 +101,37 @@ export async function createBudgetAction(
await ensureCategory(user.id, data.categoryId);
const duplicateConditions = [
eq(budgets.userId, user.id),
eq(budgets.period, data.period),
eq(budgets.categoryId, data.categoryId),
] as const;
const [createdBudget] = await db
.insert(budgets)
.values({
amount: formatDecimalForDbRequired(data.amount),
period: data.period,
userId: user.id,
categoryId: data.categoryId,
})
.onConflictDoNothing({
target: [budgets.userId, budgets.categoryId, budgets.period],
})
.returning({ id: budgets.id });
const duplicate = await db.query.budgets.findFirst({
columns: { id: true },
where: and(...duplicateConditions),
});
if (duplicate) {
if (!createdBudget) {
return {
success: false,
error:
"Já existe um orçamento para esta categoria no período selecionado.",
error: BUDGET_DUPLICATE_ERROR,
};
}
await db.insert(budgets).values({
amount: formatDecimalForDbRequired(data.amount),
period: data.period,
userId: user.id,
categoryId: data.categoryId,
});
revalidateForEntity("budgets");
revalidateForEntity("budgets", user.id);
return { success: true, message: "Orçamento criado com sucesso." };
} catch (error) {
if (hasUniqueConstraintError(error, BUDGET_UNIQUE_CONSTRAINT)) {
return {
success: false,
error: BUDGET_DUPLICATE_ERROR,
};
}
return handleActionError(error);
}
}
@@ -122,26 +145,6 @@ export async function updateBudgetAction(
await ensureCategory(user.id, data.categoryId);
const duplicateConditions = [
eq(budgets.userId, user.id),
eq(budgets.period, data.period),
eq(budgets.categoryId, data.categoryId),
ne(budgets.id, data.id),
] as const;
const duplicate = await db.query.budgets.findFirst({
columns: { id: true },
where: and(...duplicateConditions),
});
if (duplicate) {
return {
success: false,
error:
"Já existe um orçamento para esta categoria no período selecionado.",
};
}
const [updated] = await db
.update(budgets)
.set({
@@ -159,10 +162,17 @@ export async function updateBudgetAction(
};
}
revalidateForEntity("budgets");
revalidateForEntity("budgets", user.id);
return { success: true, message: "Orçamento atualizado com sucesso." };
} catch (error) {
if (hasUniqueConstraintError(error, BUDGET_UNIQUE_CONSTRAINT)) {
return {
success: false,
error: BUDGET_DUPLICATE_ERROR,
};
}
return handleActionError(error);
}
}
@@ -186,7 +196,7 @@ export async function deleteBudgetAction(
};
}
revalidateForEntity("budgets");
revalidateForEntity("budgets", user.id);
return { success: true, message: "Orçamento removido com sucesso." };
} catch (error) {
@@ -247,21 +257,35 @@ export async function duplicatePreviousMonthBudgetsAction(
};
}
// Inserir novos orçamentos
await db.insert(budgets).values(
budgetsToCopy.map((b) => ({
amount: b.amount as string,
period: data.period,
userId: user.id,
categoryId: b.categoryId as string,
})),
);
// Inserir novos orçamentos sem falhar se houver corrida com outro request.
const insertedBudgets = await db
.insert(budgets)
.values(
budgetsToCopy.map((b) => ({
amount: b.amount as string,
period: data.period,
userId: user.id,
categoryId: b.categoryId as string,
})),
)
.onConflictDoNothing({
target: [budgets.userId, budgets.categoryId, budgets.period],
})
.returning({ id: budgets.id });
revalidateForEntity("budgets");
if (insertedBudgets.length === 0) {
return {
success: false,
error:
"Todas as categories do mês anterior já possuem orçamento neste mês.",
};
}
revalidateForEntity("budgets", user.id);
return {
success: true,
message: `${budgetsToCopy.length} orçamento${budgetsToCopy.length > 1 ? "s" : ""} duplicado${budgetsToCopy.length > 1 ? "s" : ""} com sucesso.`,
message: `${insertedBudgets.length} orçamento${insertedBudgets.length > 1 ? "s" : ""} duplicado${insertedBudgets.length > 1 ? "s" : ""} com sucesso.`,
};
} catch (error) {
return handleActionError(error);

View File

@@ -65,7 +65,7 @@ async function assertAccountOwnership(userId: string, accountId: string) {
});
if (!account) {
throw new Error("FinancialAccount vinculada não encontrada.");
throw new Error("Conta vinculada não encontrada.");
}
}
@@ -93,7 +93,7 @@ export async function createCardAction(
userId: user.id,
});
revalidateForEntity("cards");
revalidateForEntity("cards", user.id);
return { success: true, message: "Cartão criado com sucesso." };
} catch (error) {
@@ -135,7 +135,7 @@ export async function updateCardAction(
};
}
revalidateForEntity("cards");
revalidateForEntity("cards", user.id);
return { success: true, message: "Cartão atualizado com sucesso." };
} catch (error) {
@@ -162,7 +162,7 @@ export async function deleteCardAction(
};
}
revalidateForEntity("cards");
revalidateForEntity("cards", user.id);
return { success: true, message: "Cartão removido com sucesso." };
} catch (error) {

View File

@@ -56,7 +56,7 @@ export async function createCategoryAction(
userId: user.id,
});
revalidateForEntity("categories");
revalidateForEntity("categories", user.id);
return { success: true, message: "Category criada com sucesso." };
} catch (error) {
@@ -114,7 +114,7 @@ export async function updateCategoryAction(
};
}
revalidateForEntity("categories");
revalidateForEntity("categories", user.id);
return { success: true, message: "Category atualizada com sucesso." };
} catch (error) {
@@ -167,7 +167,7 @@ export async function deleteCategoryAction(
};
}
revalidateForEntity("categories");
revalidateForEntity("categories", user.id);
return { success: true, message: "Category removida com sucesso." };
} catch (error) {

View File

@@ -2,7 +2,7 @@
import { and, eq, sql } from "drizzle-orm";
import { z } from "zod";
import { cards, categories, invoices, payers, transactions } from "@/db/schema";
import { cards, categories, invoices, transactions } from "@/db/schema";
import { buildInvoicePaymentNote } from "@/shared/lib/accounts/constants";
import { revalidateForEntity } from "@/shared/lib/actions/helpers";
import { getUser } from "@/shared/lib/auth/server";
@@ -13,7 +13,7 @@ import {
type InvoicePaymentStatus,
PERIOD_FORMAT_REGEX,
} from "@/shared/lib/invoices";
import { PAYER_ROLE_ADMIN } from "@/shared/lib/payers/constants";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
import {
getBusinessTodayDate,
parseLocalDateString,
@@ -60,6 +60,7 @@ export async function updateInvoicePaymentStatusAction(
try {
const user = await getUser();
const data = updateInvoicePaymentStatusSchema.parse(input);
const adminPayerId = await getAdminPayerId(user.id);
await db.transaction(async (tx: typeof db) => {
const card = await tx.query.cards.findFirst({
@@ -71,32 +72,20 @@ export async function updateInvoicePaymentStatusAction(
throw new Error("Cartão não encontrado.");
}
const existingInvoice = await tx.query.invoices.findFirst({
columns: {
id: true,
},
where: and(
eq(invoices.cardId, data.cardId),
eq(invoices.userId, user.id),
eq(invoices.period, data.period),
),
});
if (existingInvoice) {
await tx
.update(invoices)
.set({
paymentStatus: data.status,
})
.where(eq(invoices.id, existingInvoice.id));
} else {
await tx.insert(invoices).values({
await tx
.insert(invoices)
.values({
cardId: data.cardId,
period: data.period,
paymentStatus: data.status,
userId: user.id,
})
.onConflictDoUpdate({
target: [invoices.userId, invoices.cardId, invoices.period],
set: {
paymentStatus: data.status,
},
});
}
const shouldMarkAsPaid = data.status === INVOICE_PAYMENT_STATUS.PAID;
@@ -114,38 +103,26 @@ export async function updateInvoicePaymentStatusAction(
const invoiceNote = buildInvoicePaymentNote(card.id, data.period);
if (shouldMarkAsPaid) {
const [adminShareRow] = await tx
.select({
total: sql<number>`
coalesce(
sum(${transactions.amount}),
0
)
`,
})
.from(transactions)
.leftJoin(payers, eq(transactions.payerId, payers.id))
.where(
and(
eq(transactions.userId, user.id),
eq(transactions.cardId, card.id),
eq(transactions.period, data.period),
eq(payers.role, PAYER_ROLE_ADMIN),
),
);
const [adminShareRow] = adminPayerId
? await tx
.select({
total: sql<number>`coalesce(sum(${transactions.amount}), 0)`,
})
.from(transactions)
.where(
and(
eq(transactions.userId, user.id),
eq(transactions.cardId, card.id),
eq(transactions.period, data.period),
eq(transactions.payerId, adminPayerId),
),
)
: [{ total: 0 }];
const adminShare = Number(adminShareRow?.total ?? 0);
const adminPayableAmount = Math.abs(Math.min(adminShare, 0));
if (adminPayableAmount > 0 && card.accountId) {
const adminPagador = await tx.query.payers.findFirst({
columns: { id: true },
where: and(
eq(payers.userId, user.id),
eq(payers.role, PAYER_ROLE_ADMIN),
),
});
if (card.accountId && adminPayerId) {
const paymentCategory = await tx.query.categories.findFirst({
columns: { id: true },
where: and(
@@ -154,45 +131,43 @@ export async function updateInvoicePaymentStatusAction(
),
});
if (adminPagador) {
// Usar a data customizada ou a data atual como data de pagamento
const invoiceDate = data.paymentDate
? parseLocalDateString(data.paymentDate)
: getBusinessTodayDate();
// Usar a data customizada ou a data atual como data de pagamento
const invoiceDate = data.paymentDate
? parseLocalDateString(data.paymentDate)
: getBusinessTodayDate();
const amount = `-${formatDecimal(adminPayableAmount)}`;
const payload = {
condition: "À vista",
name: `Pagamento fatura - ${card.name}`,
paymentMethod: "Pix",
note: invoiceNote,
amount,
purchaseDate: invoiceDate,
transactionType: "Despesa" as const,
period: data.period,
isSettled: true,
userId: user.id,
accountId: card.accountId,
categoryId: paymentCategory?.id ?? null,
payerId: adminPagador.id,
};
const amount = `-${formatDecimal(adminPayableAmount)}`;
const payload = {
condition: "À vista",
name: `Pagamento fatura - ${card.name}`,
paymentMethod: "Pix",
note: invoiceNote,
amount,
purchaseDate: invoiceDate,
transactionType: "Despesa" as const,
period: data.period,
isSettled: true,
userId: user.id,
accountId: card.accountId,
categoryId: paymentCategory?.id ?? null,
payerId: adminPayerId,
};
const existingPayment = await tx.query.transactions.findFirst({
columns: { id: true },
where: and(
eq(transactions.userId, user.id),
eq(transactions.note, invoiceNote),
),
});
const existingPayment = await tx.query.transactions.findFirst({
columns: { id: true },
where: and(
eq(transactions.userId, user.id),
eq(transactions.note, invoiceNote),
),
});
if (existingPayment) {
await tx
.update(transactions)
.set(payload)
.where(eq(transactions.id, existingPayment.id));
} else {
await tx.insert(transactions).values(payload);
}
if (existingPayment) {
await tx
.update(transactions)
.set(payload)
.where(eq(transactions.id, existingPayment.id));
} else {
await tx.insert(transactions).values(payload);
}
}
} else {
@@ -207,7 +182,7 @@ export async function updateInvoicePaymentStatusAction(
}
});
revalidateForEntity("cards");
revalidateForEntity("cards", user.id);
return { success: true, message: successMessageByStatus[data.status] };
} catch (error) {
@@ -278,7 +253,7 @@ export async function updatePaymentDateAction(
.where(eq(transactions.id, existingPayment.id));
});
revalidateForEntity("cards");
revalidateForEntity("cards", user.id);
return { success: true, message: "Data de pagamento atualizada." };
} catch (error) {

View File

@@ -84,7 +84,7 @@ export async function createNoteAction(
userId: user.id,
});
revalidateForEntity("notes");
revalidateForEntity("notes", user.id);
return { success: true, message: "Anotação criada com sucesso." };
} catch (error) {
@@ -120,7 +120,7 @@ export async function updateNoteAction(
};
}
revalidateForEntity("notes");
revalidateForEntity("notes", user.id);
return { success: true, message: "Anotação atualizada com sucesso." };
} catch (error) {
@@ -147,7 +147,7 @@ export async function deleteNoteAction(
};
}
revalidateForEntity("notes");
revalidateForEntity("notes", user.id);
return { success: true, message: "Anotação removida com sucesso." };
} catch (error) {
@@ -184,7 +184,7 @@ export async function archiveNoteAction(
};
}
revalidateForEntity("notes");
revalidateForEntity("notes", user.id);
return {
success: true,

View File

@@ -81,7 +81,7 @@ type ShareDeleteInput = z.infer<typeof shareDeleteSchema>;
type ShareCodeJoinInput = z.infer<typeof shareCodeJoinSchema>;
type ShareCodeRegenerateInput = z.infer<typeof shareCodeRegenerateSchema>;
const revalidate = () => revalidateForEntity("payers");
const revalidate = (userId: string) => revalidateForEntity("payers", userId);
const generateShareCode = () => {
// base64url já retorna apenas [a-zA-Z0-9_-]
@@ -108,7 +108,7 @@ export async function createPayerAction(
userId: user.id,
});
revalidate();
revalidate(user.id);
return { success: true, message: "Payer criado com sucesso." };
} catch (error) {
@@ -158,7 +158,7 @@ export async function updatePayerAction(
revalidatePath("/", "layout");
}
revalidate();
revalidate(currentUser.id);
return { success: true, message: "Payer atualizado com sucesso." };
} catch (error) {
@@ -195,7 +195,7 @@ export async function deletePayerAction(
.delete(payers)
.where(and(eq(payers.id, data.id), eq(payers.userId, user.id)));
revalidate();
revalidate(user.id);
return { success: true, message: "Payer removido com sucesso." };
} catch (error) {
@@ -246,7 +246,7 @@ export async function joinPayerByShareCodeAction(
createdByUserId: pagadorRow.userId,
});
revalidate();
revalidate(user.id);
return { success: true, message: "Payer adicionado à sua lista." };
} catch (error) {
@@ -291,7 +291,7 @@ export async function deletePayerShareAction(
await db.delete(payerShares).where(eq(payerShares.id, data.shareId));
revalidate();
revalidate(user.id);
revalidatePath(`/payers/${existing.payerId}`);
return { success: true, message: "Compartilhamento removido." };
@@ -325,7 +325,7 @@ export async function regeneratePayerShareCodeAction(
.set({ shareCode: newCode })
.where(and(eq(payers.id, data.payerId), eq(payers.userId, user.id)));
revalidate();
revalidate(user.id);
revalidatePath(`/payers/${data.payerId}`);
return {
success: true,

View File

@@ -2,14 +2,22 @@
import { createHash, randomBytes } from "node:crypto";
import { verifyPassword } from "better-auth/crypto";
import { and, eq, isNull, ne } from "drizzle-orm";
import { and, eq, isNull, ne, or } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { headers } from "next/headers";
import { z } from "zod";
import { account, apiTokens, payers } from "@/db/schema";
import { revalidateForEntity } from "@/shared/lib/actions/helpers";
import { auth } from "@/shared/lib/auth/config";
import { DEFAULT_CATEGORIES } from "@/shared/lib/categories/defaults";
import { db, schema } from "@/shared/lib/db";
import { PAYER_ROLE_ADMIN } from "@/shared/lib/payers/constants";
import {
DEFAULT_PAYER_AVATAR,
PAYER_ROLE_ADMIN,
PAYER_STATUS_OPTIONS,
} from "@/shared/lib/payers/constants";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
import { normalizeNameFromEmail } from "@/shared/lib/payers/utils";
type ActionResponse<T = void> = {
success: boolean;
@@ -50,11 +58,96 @@ const deleteAccountSchema = z.object({
confirmation: z.literal("DELETAR"),
});
const resetAccountSchema = z.object({
confirmation: z.literal("ZERAR"),
});
const updatePreferencesSchema = z.object({
statementNoteAsColumn: z.boolean(),
transactionsColumnOrder: z.array(z.string()).nullable(),
});
type ResettableUser = {
name: string | null;
email: string | null;
image: string | null;
};
async function resetUserAppData(
userId: string,
user: ResettableUser,
): Promise<void> {
const payerName =
(user.name && user.name.trim().length > 0
? user.name.trim()
: normalizeNameFromEmail(user.email)) || "Payer principal";
const avatarUrl = user.image ?? DEFAULT_PAYER_AVATAR;
const defaultPayerStatus = PAYER_STATUS_OPTIONS[0];
await db.transaction(async (tx: typeof db) => {
await tx
.delete(schema.payerShares)
.where(
or(
eq(schema.payerShares.sharedWithUserId, userId),
eq(schema.payerShares.createdByUserId, userId),
),
);
await tx
.delete(schema.userPreferences)
.where(eq(schema.userPreferences.userId, userId));
await tx
.delete(schema.apiTokens)
.where(eq(schema.apiTokens.userId, userId));
await tx
.delete(schema.savedInsights)
.where(eq(schema.savedInsights.userId, userId));
await tx.delete(schema.notes).where(eq(schema.notes.userId, userId));
await tx
.delete(schema.inboxItems)
.where(eq(schema.inboxItems.userId, userId));
await tx.delete(schema.budgets).where(eq(schema.budgets.userId, userId));
await tx
.delete(schema.installmentAnticipations)
.where(eq(schema.installmentAnticipations.userId, userId));
await tx
.delete(schema.transactions)
.where(eq(schema.transactions.userId, userId));
await tx.delete(schema.invoices).where(eq(schema.invoices.userId, userId));
await tx.delete(schema.cards).where(eq(schema.cards.userId, userId));
await tx
.delete(schema.financialAccounts)
.where(eq(schema.financialAccounts.userId, userId));
await tx.delete(schema.payers).where(eq(schema.payers.userId, userId));
await tx
.delete(schema.categories)
.where(eq(schema.categories.userId, userId));
if (DEFAULT_CATEGORIES.length > 0) {
await tx.insert(schema.categories).values(
DEFAULT_CATEGORIES.map((category) => ({
name: category.name,
type: category.type,
icon: category.icon,
userId,
})),
);
}
await tx.insert(schema.payers).values({
name: payerName,
email: user.email,
avatarUrl,
status: defaultPayerStatus,
note: null,
role: PAYER_ROLE_ADMIN,
isAutoSend: false,
userId,
});
});
}
// Actions
export async function updateNameAction(
@@ -74,6 +167,7 @@ export async function updateNameAction(
const validated = updateNameSchema.parse(data);
const fullName = `${validated.firstName} ${validated.lastName}`;
const adminPayerId = await getAdminPayerId(session.user.id);
// Atualizar nome do usuário
await db
@@ -82,15 +176,14 @@ export async function updateNameAction(
.where(eq(schema.user.id, session.user.id));
// Sincronizar nome com o pagador admin
await db
.update(payers)
.set({ name: fullName })
.where(
and(
eq(payers.userId, session.user.id),
eq(payers.role, PAYER_ROLE_ADMIN),
),
);
if (adminPayerId) {
await db
.update(payers)
.set({ name: fullName })
.where(
and(eq(payers.userId, session.user.id), eq(payers.id, adminPayerId)),
);
}
// Revalidar o layout do dashboard para atualizar a sidebar
revalidatePath("/", "layout");
@@ -251,7 +344,7 @@ export async function updateEmailAction(
if (!storedHash) {
return {
success: false,
error: "FinancialAccount de credencial não encontrada.",
error: "Conta de credencial não encontrada.",
};
}
@@ -348,7 +441,7 @@ export async function deleteAccountAction(
return {
success: true,
message: "FinancialAccount deletada com sucesso",
message: "Conta deletada com sucesso.",
};
} catch (error) {
if (error instanceof z.ZodError) {
@@ -366,6 +459,75 @@ export async function deleteAccountAction(
}
}
export async function resetAccountAction(
data: z.infer<typeof resetAccountSchema>,
): Promise<ActionResponse> {
try {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session?.user?.id) {
return {
success: false,
error: "Não autenticado",
};
}
resetAccountSchema.parse(data);
const currentUser = await db.query.user.findFirst({
columns: {
name: true,
email: true,
image: true,
},
where: eq(schema.user.id, session.user.id),
});
if (!currentUser) {
return {
success: false,
error: "Usuário não encontrado.",
};
}
await resetUserAppData(session.user.id, currentUser);
revalidateForEntity("accounts", session.user.id);
revalidateForEntity("cards", session.user.id);
revalidateForEntity("categories", session.user.id);
revalidateForEntity("budgets", session.user.id);
revalidateForEntity("payers", session.user.id);
revalidateForEntity("notes", session.user.id);
revalidateForEntity("transactions", session.user.id);
revalidateForEntity("inbox", session.user.id);
revalidatePath("/settings");
revalidatePath("/insights");
revalidatePath("/reports");
revalidatePath("/calendar");
revalidatePath("/", "layout");
return {
success: true,
message: "Conta zerada com sucesso.",
};
} catch (error) {
if (error instanceof z.ZodError) {
return {
success: false,
error: error.issues[0]?.message || "Dados inválidos",
};
}
console.error("Erro ao zerar conta:", error);
return {
success: false,
error: "Erro ao zerar conta. Tente novamente.",
};
}
}
export async function updatePreferencesAction(
data: z.infer<typeof updatePreferencesSchema>,
): Promise<ActionResponse> {
@@ -557,7 +719,12 @@ export async function revokeApiTokenAction(
.set({
revokedAt: new Date(),
})
.where(eq(apiTokens.id, validated.tokenId));
.where(
and(
eq(apiTokens.id, validated.tokenId),
eq(apiTokens.userId, session.user.id),
),
);
revalidatePath("/settings");

View File

@@ -2,7 +2,10 @@
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { toast } from "sonner";
import { deleteAccountAction } from "@/features/settings/actions";
import {
deleteAccountAction,
resetAccountAction,
} from "@/features/settings/actions";
import { Button } from "@/shared/components/ui/button";
import {
Dialog,
@@ -16,68 +19,145 @@ import { Input } from "@/shared/components/ui/input";
import { Label } from "@/shared/components/ui/label";
import { authClient } from "@/shared/lib/auth/client";
const RESET_CONFIRMATION = "ZERAR";
const DELETE_CONFIRMATION = "DELETAR";
type DangerAction = "reset" | "delete";
export function DeleteAccountForm() {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [isModalOpen, setIsModalOpen] = useState(false);
const [dangerAction, setDangerAction] = useState<DangerAction | null>(null);
const [confirmation, setConfirmation] = useState("");
const handleDelete = () => {
const handleAction = () => {
if (!dangerAction) return;
const currentAction = dangerAction;
startTransition(async () => {
const result = await deleteAccountAction({
confirmation: confirmation as "DELETAR",
});
const result =
currentAction === "reset"
? await resetAccountAction({
confirmation: confirmation as typeof RESET_CONFIRMATION,
})
: await deleteAccountAction({
confirmation: confirmation as typeof DELETE_CONFIRMATION,
});
if (result.success) {
toast.success(result.message);
// Fazer logout e redirecionar para página de login
await authClient.signOut();
router.push("/");
if (currentAction === "delete") {
await authClient.signOut();
router.push("/");
return;
}
setConfirmation("");
setDangerAction(null);
router.refresh();
} else {
toast.error(result.error);
}
});
};
const handleOpenModal = () => {
const handleOpenModal = (action: DangerAction) => {
setConfirmation("");
setIsModalOpen(true);
setDangerAction(action);
};
const handleCloseModal = () => {
if (isPending) return;
setConfirmation("");
setIsModalOpen(false);
setDangerAction(null);
};
const confirmationWord =
dangerAction === "reset" ? RESET_CONFIRMATION : DELETE_CONFIRMATION;
const isResetAction = dangerAction === "reset";
return (
<>
<div className="flex flex-col space-y-6">
<div className="space-y-4 max-w-md">
<ul className="list-disc list-inside text-sm text-destructive space-y-1">
<li>Lançamentos, orçamentos e anotações</li>
<li>Contas, cartões e categorias</li>
<li>Pagadores (incluindo o pagador padrão)</li>
<li>Preferências e configurações</li>
<li className="font-bold">
Resumindo tudo, sua conta será permanentemente removida
</li>
</ul>
<div className="rounded-lg border p-4">
<div className="space-y-4">
<div>
<h3 className="font-semibold">Zerar conta</h3>
<p className="text-sm text-muted-foreground">
Apaga todos os dados do OpenMonetis e deixa sua conta no estado
inicial, mantendo seu login e credenciais de acesso.
</p>
</div>
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1">
<li>Lançamentos, faturas, antecipações e pré-lançamentos</li>
<li>Contas, cartões, orçamentos e anotações</li>
<li>Pagadores próprios e compartilhamentos recebidos</li>
<li>
Preferências do app, insights salvos e tokens do Companion
</li>
<li className="font-medium text-foreground">
Categorias padrão e pagador admin serão recriados
automaticamente
</li>
</ul>
<div className="flex justify-end">
<Button
variant="outline"
onClick={() => handleOpenModal("reset")}
disabled={isPending}
className="w-fit border-destructive/30 text-destructive hover:bg-destructive/10 hover:text-destructive"
>
Zerar conta
</Button>
</div>
</div>
</div>
<div className="flex justify-end">
<Button
variant="destructive"
onClick={handleOpenModal}
disabled={isPending}
className="w-fit"
>
Deletar conta
</Button>
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4">
<div className="space-y-4">
<div>
<h3 className="font-semibold text-destructive">Deletar conta</h3>
<p className="text-sm text-muted-foreground">
Remove seu usuário e todos os dados associados de forma
permanente.
</p>
</div>
<ul className="list-disc list-inside text-sm text-destructive space-y-1">
<li>Lançamentos, orçamentos e anotações</li>
<li>Contas, cartões e categorias</li>
<li>Pagadores, credenciais e configurações</li>
<li className="font-bold">
Resumindo tudo, sua conta será permanentemente removida
</li>
</ul>
<div className="flex justify-end">
<Button
variant="destructive"
onClick={() => handleOpenModal("delete")}
disabled={isPending}
className="w-fit"
>
Deletar conta
</Button>
</div>
</div>
</div>
</div>
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<Dialog
open={dangerAction !== null}
onOpenChange={(isOpen) => {
if (!isOpen) {
handleCloseModal();
}
}}
>
<DialogContent
className="max-w-md"
onEscapeKeyDown={(e) => {
@@ -88,24 +168,28 @@ export function DeleteAccountForm() {
}}
>
<DialogHeader>
<DialogTitle>Você tem certeza?</DialogTitle>
<DialogTitle>
{isResetAction ? "Zerar sua conta?" : "Você tem certeza?"}
</DialogTitle>
<DialogDescription>
Essa ação não pode ser desfeita. Isso irá deletar permanentemente
sua conta e remover seus dados de nossos servidores.
{isResetAction
? "Essa ação não pode ser desfeita. Todos os dados do app serão apagados e sua conta voltará ao estado inicial, mas seu login continuará existindo."
: "Essa ação não pode ser desfeita. Isso irá deletar permanentemente sua conta e remover seus dados de nossos servidores."}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="confirmation">
Para confirmar, digite <strong>DELETAR</strong> no campo abaixo.
Para confirmar, digite <strong>{confirmationWord}</strong> no
campo abaixo.
</Label>
<Input
id="confirmation"
value={confirmation}
onChange={(e) => setConfirmation(e.target.value)}
disabled={isPending}
placeholder="DELETAR"
placeholder={confirmationWord}
autoComplete="off"
/>
</div>
@@ -122,11 +206,22 @@ export function DeleteAccountForm() {
</Button>
<Button
type="button"
variant="destructive"
onClick={handleDelete}
disabled={isPending || confirmation !== "DELETAR"}
variant={isResetAction ? "outline" : "destructive"}
onClick={handleAction}
disabled={isPending || confirmation !== confirmationWord}
className={
isResetAction
? "border-destructive/30 text-destructive hover:bg-destructive/10 hover:text-destructive"
: undefined
}
>
{isPending ? "Deletando..." : "Deletar"}
{isPending
? isResetAction
? "Zerando..."
: "Deletando..."
: isResetAction
? "Zerar conta"
: "Deletar"}
</Button>
</DialogFooter>
</DialogContent>

View File

@@ -263,10 +263,15 @@ export async function createInstallmentAnticipationAction(
anticipationId: anticipation.id,
amount: "0", // Zera o valor para não contar em dobro
})
.where(inArray(transactions.id, data.installmentIds));
.where(
and(
inArray(transactions.id, data.installmentIds),
eq(transactions.userId, user.id),
),
);
});
revalidateForEntity("transactions");
revalidateForEntity("transactions", user.id);
return {
success: true,
@@ -418,24 +423,37 @@ export async function cancelInstallmentAnticipationAction(
amount: formatDecimalForDbRequired(originalValuePerInstallment),
})
.where(
inArray(
transactions.id,
anticipation.anticipatedInstallmentIds as string[],
and(
inArray(
transactions.id,
anticipation.anticipatedInstallmentIds as string[],
),
eq(transactions.userId, user.id),
),
);
// 5. Deletar lançamento de antecipação
await tx
.delete(transactions)
.where(eq(transactions.id, anticipation.transactionId));
.where(
and(
eq(transactions.id, anticipation.transactionId),
eq(transactions.userId, user.id),
),
);
// 6. Deletar registro de antecipação
await tx
.delete(installmentAnticipations)
.where(eq(installmentAnticipations.id, data.anticipationId));
.where(
and(
eq(installmentAnticipations.id, data.anticipationId),
eq(installmentAnticipations.userId, user.id),
),
);
});
revalidateForEntity("transactions");
revalidateForEntity("transactions", user.id);
return {
success: true,