mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-09 02:51:46 +00:00
- botão "Ajustar fatura" na página da fatura abre dialog com input do valor real e preview da diferença; action faz upsert/delete idempotente do lançamento de ajuste - opção "Reembolso" no dropdown de ações de despesas à vista cria receita espelhada no extrato ou fatura correta, vinculada ao lançamento original - botão "Ajustar saldo" no extrato da conta compara saldo real informado e gera lançamento de ajuste por (accountId, period) via upsert/delete idempotente - constantes INVOICE_ADJUSTMENT_NAME, ACCOUNT_BALANCE_ADJUSTMENT_NAME, REFUND_NOTE_PREFIX e buildRefundNote() centralizadas em shared/lib/accounts/constants.ts - extrato agora contabiliza transferências internas em Entradas e Saídas corretamente Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
516 lines
14 KiB
TypeScript
516 lines
14 KiB
TypeScript
"use server";
|
|
|
|
import { and, eq } from "drizzle-orm";
|
|
import { z } from "zod";
|
|
import { categories, financialAccounts, transactions } from "@/db/schema";
|
|
import {
|
|
ACCOUNT_BALANCE_ADJUSTMENT_NAME,
|
|
INITIAL_BALANCE_CATEGORY_NAME,
|
|
INITIAL_BALANCE_CONDITION,
|
|
INITIAL_BALANCE_NOTE,
|
|
INITIAL_BALANCE_PAYMENT_METHOD,
|
|
INITIAL_BALANCE_TRANSACTION_TYPE,
|
|
} from "@/shared/lib/accounts/constants";
|
|
import {
|
|
type ActionResult,
|
|
handleActionError,
|
|
revalidateForEntity,
|
|
} from "@/shared/lib/actions/helpers";
|
|
import { getUser } from "@/shared/lib/auth/server";
|
|
import { db } from "@/shared/lib/db";
|
|
import { PERIOD_FORMAT_REGEX } from "@/shared/lib/invoices";
|
|
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
|
|
import { noteSchema, uuidSchema } from "@/shared/lib/schemas/common";
|
|
import {
|
|
TRANSFER_CATEGORY_NAME,
|
|
TRANSFER_CONDITION,
|
|
TRANSFER_ESTABLISHMENT_ENTRADA,
|
|
TRANSFER_ESTABLISHMENT_SAIDA,
|
|
TRANSFER_PAYMENT_METHOD,
|
|
} from "@/shared/lib/transfers/constants";
|
|
import {
|
|
formatCurrency,
|
|
formatDecimalForDbRequired,
|
|
} from "@/shared/utils/currency";
|
|
import { getBusinessTodayDate, getTodayInfo } from "@/shared/utils/date";
|
|
import { normalizeFilePath } from "@/shared/utils/string";
|
|
|
|
const accountBaseSchema = z.object({
|
|
name: z
|
|
.string({ message: "Informe o nome da conta." })
|
|
.trim()
|
|
.min(1, "Informe o nome da conta."),
|
|
accountType: z
|
|
.string({ message: "Informe o tipo da conta." })
|
|
.trim()
|
|
.min(1, "Informe o tipo da conta."),
|
|
status: z
|
|
.string({ message: "Informe o status da conta." })
|
|
.trim()
|
|
.min(1, "Informe o status da conta."),
|
|
note: noteSchema,
|
|
logo: z
|
|
.string({ message: "Selecione um logo." })
|
|
.trim()
|
|
.min(1, "Selecione um logo."),
|
|
initialBalance: z.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"),
|
|
excludeInitialBalanceFromIncome: z
|
|
.union([z.boolean(), z.string()])
|
|
.transform((value) => value === true || value === "true"),
|
|
});
|
|
|
|
const createAccountSchema = accountBaseSchema;
|
|
const updateAccountSchema = accountBaseSchema.extend({
|
|
id: uuidSchema("FinancialAccount"),
|
|
});
|
|
const deleteAccountSchema = z.object({
|
|
id: uuidSchema("FinancialAccount"),
|
|
});
|
|
|
|
type AccountCreateInput = z.infer<typeof createAccountSchema>;
|
|
type AccountUpdateInput = z.infer<typeof updateAccountSchema>;
|
|
type AccountDeleteInput = z.infer<typeof deleteAccountSchema>;
|
|
|
|
export async function createAccountAction(
|
|
input: AccountCreateInput,
|
|
): Promise<ActionResult> {
|
|
try {
|
|
const user = await getUser();
|
|
const data = createAccountSchema.parse(input);
|
|
|
|
const logoFile = normalizeFilePath(data.logo);
|
|
|
|
const normalizedInitialBalance = Math.abs(data.initialBalance);
|
|
const hasInitialBalance = normalizedInitialBalance > 0;
|
|
const adminPayerId = hasInitialBalance
|
|
? await getAdminPayerId(user.id)
|
|
: null;
|
|
|
|
if (hasInitialBalance && !adminPayerId) {
|
|
throw new Error(
|
|
"Pessoa com papel administrador não encontrada. Crie uma pessoa admin antes de definir um saldo inicial.",
|
|
);
|
|
}
|
|
|
|
await db.transaction(async (tx: typeof db) => {
|
|
const [createdAccount] = await tx
|
|
.insert(financialAccounts)
|
|
.values({
|
|
name: data.name,
|
|
accountType: data.accountType,
|
|
status: data.status,
|
|
note: data.note ?? null,
|
|
logo: logoFile,
|
|
initialBalance: formatDecimalForDbRequired(data.initialBalance),
|
|
excludeFromBalance: data.excludeFromBalance,
|
|
excludeInitialBalanceFromIncome: data.excludeInitialBalanceFromIncome,
|
|
userId: user.id,
|
|
})
|
|
.returning({ id: financialAccounts.id, name: financialAccounts.name });
|
|
|
|
if (!createdAccount) {
|
|
throw new Error("Não foi possível criar a conta.");
|
|
}
|
|
|
|
if (!hasInitialBalance) {
|
|
return;
|
|
}
|
|
|
|
const [category] = await Promise.all([
|
|
tx.query.categories.findFirst({
|
|
columns: { id: true },
|
|
where: and(
|
|
eq(categories.userId, user.id),
|
|
eq(categories.name, INITIAL_BALANCE_CATEGORY_NAME),
|
|
),
|
|
}),
|
|
]);
|
|
|
|
if (!category) {
|
|
throw new Error(
|
|
'Category "Saldo inicial" não encontrada. Crie-a antes de definir um saldo inicial.',
|
|
);
|
|
}
|
|
|
|
const { date, period } = getTodayInfo();
|
|
|
|
await tx.insert(transactions).values({
|
|
condition: INITIAL_BALANCE_CONDITION,
|
|
name: `Saldo inicial - ${createdAccount.name}`,
|
|
paymentMethod: INITIAL_BALANCE_PAYMENT_METHOD,
|
|
note: INITIAL_BALANCE_NOTE,
|
|
amount: formatDecimalForDbRequired(normalizedInitialBalance),
|
|
purchaseDate: date,
|
|
transactionType: INITIAL_BALANCE_TRANSACTION_TYPE,
|
|
period,
|
|
isSettled: true,
|
|
userId: user.id,
|
|
accountId: createdAccount.id,
|
|
categoryId: category.id,
|
|
payerId: adminPayerId,
|
|
});
|
|
});
|
|
|
|
revalidateForEntity("accounts", user.id);
|
|
|
|
return {
|
|
success: true,
|
|
message: "Conta criada com sucesso.",
|
|
};
|
|
} catch (error) {
|
|
return handleActionError(error);
|
|
}
|
|
}
|
|
|
|
export async function updateAccountAction(
|
|
input: AccountUpdateInput,
|
|
): Promise<ActionResult> {
|
|
try {
|
|
const user = await getUser();
|
|
const data = updateAccountSchema.parse(input);
|
|
|
|
const logoFile = normalizeFilePath(data.logo);
|
|
|
|
const [updated] = await db
|
|
.update(financialAccounts)
|
|
.set({
|
|
name: data.name,
|
|
accountType: data.accountType,
|
|
status: data.status,
|
|
note: data.note ?? null,
|
|
logo: logoFile,
|
|
initialBalance: formatDecimalForDbRequired(data.initialBalance),
|
|
excludeFromBalance: data.excludeFromBalance,
|
|
excludeInitialBalanceFromIncome: data.excludeInitialBalanceFromIncome,
|
|
})
|
|
.where(
|
|
and(
|
|
eq(financialAccounts.id, data.id),
|
|
eq(financialAccounts.userId, user.id),
|
|
),
|
|
)
|
|
.returning();
|
|
|
|
if (!updated) {
|
|
return {
|
|
success: false,
|
|
error: "Conta não encontrada.",
|
|
};
|
|
}
|
|
|
|
revalidateForEntity("accounts", user.id);
|
|
|
|
return {
|
|
success: true,
|
|
message: "Conta atualizada com sucesso.",
|
|
};
|
|
} catch (error) {
|
|
return handleActionError(error);
|
|
}
|
|
}
|
|
|
|
export async function deleteAccountAction(
|
|
input: AccountDeleteInput,
|
|
): Promise<ActionResult> {
|
|
try {
|
|
const user = await getUser();
|
|
const data = deleteAccountSchema.parse(input);
|
|
|
|
const [deleted] = await db
|
|
.delete(financialAccounts)
|
|
.where(
|
|
and(
|
|
eq(financialAccounts.id, data.id),
|
|
eq(financialAccounts.userId, user.id),
|
|
),
|
|
)
|
|
.returning({ id: financialAccounts.id });
|
|
|
|
if (!deleted) {
|
|
return {
|
|
success: false,
|
|
error: "Conta não encontrada.",
|
|
};
|
|
}
|
|
|
|
revalidateForEntity("accounts", user.id);
|
|
|
|
return {
|
|
success: true,
|
|
message: "Conta removida com sucesso.",
|
|
};
|
|
} catch (error) {
|
|
return handleActionError(error);
|
|
}
|
|
}
|
|
|
|
// Transfer between accounts
|
|
const transferSchema = z.object({
|
|
fromAccountId: uuidSchema("Conta de origem"),
|
|
toAccountId: uuidSchema("Conta de destino"),
|
|
amount: z
|
|
.string()
|
|
.trim()
|
|
.transform((value) => (value.length === 0 ? "0" : value.replace(",", ".")))
|
|
.refine(
|
|
(value) => !Number.isNaN(Number.parseFloat(value)),
|
|
"Informe um valor válido.",
|
|
)
|
|
.transform((value) => Number.parseFloat(value))
|
|
.refine((value) => value > 0, "O valor deve ser maior que zero."),
|
|
date: z.coerce.date({ message: "Informe uma data válida." }),
|
|
period: z
|
|
.string({ message: "Informe o período." })
|
|
.trim()
|
|
.min(1, "Informe o período."),
|
|
});
|
|
|
|
type TransferInput = z.input<typeof transferSchema>;
|
|
|
|
export async function transferBetweenAccountsAction(
|
|
input: TransferInput,
|
|
): Promise<ActionResult> {
|
|
try {
|
|
const user = await getUser();
|
|
const data = transferSchema.parse(input);
|
|
|
|
// Validate that accounts are different
|
|
if (data.fromAccountId === data.toAccountId) {
|
|
return {
|
|
success: false,
|
|
error: "A conta de origem e destino devem ser diferentes.",
|
|
};
|
|
}
|
|
|
|
// Generate a unique transfer ID to link both transactions
|
|
const transferId = crypto.randomUUID();
|
|
const adminPayerId = await getAdminPayerId(user.id);
|
|
|
|
if (!adminPayerId) {
|
|
throw new Error(
|
|
"Pessoa administrador não encontrada. Por favor, crie uma pessoa admin.",
|
|
);
|
|
}
|
|
|
|
await db.transaction(async (tx: typeof db) => {
|
|
// Verify both accounts exist and belong to the user
|
|
const [fromAccount, toAccount] = await Promise.all([
|
|
tx.query.financialAccounts.findFirst({
|
|
columns: { id: true, name: true },
|
|
where: and(
|
|
eq(financialAccounts.id, data.fromAccountId),
|
|
eq(financialAccounts.userId, user.id),
|
|
),
|
|
}),
|
|
tx.query.financialAccounts.findFirst({
|
|
columns: { id: true, name: true },
|
|
where: and(
|
|
eq(financialAccounts.id, data.toAccountId),
|
|
eq(financialAccounts.userId, user.id),
|
|
),
|
|
}),
|
|
]);
|
|
|
|
if (!fromAccount) {
|
|
throw new Error("Conta de origem não encontrada.");
|
|
}
|
|
|
|
if (!toAccount) {
|
|
throw new Error("Conta de destino não encontrada.");
|
|
}
|
|
|
|
// 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(
|
|
`Category "${TRANSFER_CATEGORY_NAME}" não encontrada. Por favor, crie esta categoria antes de fazer transferências.`,
|
|
);
|
|
}
|
|
|
|
const transferNote = `de ${fromAccount.name} -> ${toAccount.name}`;
|
|
|
|
const sharedFields = {
|
|
condition: TRANSFER_CONDITION,
|
|
paymentMethod: TRANSFER_PAYMENT_METHOD,
|
|
note: transferNote,
|
|
purchaseDate: data.date,
|
|
transactionType: "Transferência" as const,
|
|
period: data.period,
|
|
isSettled: true,
|
|
userId: user.id,
|
|
categoryId: transferCategory.id,
|
|
payerId: adminPayerId,
|
|
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", user.id);
|
|
revalidateForEntity("transactions", user.id);
|
|
|
|
return {
|
|
success: true,
|
|
message: "Transferência registrada com sucesso.",
|
|
};
|
|
} catch (error) {
|
|
return handleActionError(error);
|
|
}
|
|
}
|
|
|
|
const adjustAccountBalanceSchema = z.object({
|
|
accountId: uuidSchema("FinancialAccount"),
|
|
period: z
|
|
.string({ message: "Período inválido." })
|
|
.regex(PERIOD_FORMAT_REGEX, "Período inválido."),
|
|
currentBalance: z.number({ message: "Saldo atual inválido." }),
|
|
targetBalance: z.number({ message: "Saldo correto inválido." }),
|
|
});
|
|
|
|
type AdjustAccountBalanceInput = z.infer<typeof adjustAccountBalanceSchema>;
|
|
|
|
export async function adjustAccountBalanceAction(
|
|
input: AdjustAccountBalanceInput,
|
|
): Promise<ActionResult> {
|
|
try {
|
|
const user = await getUser();
|
|
const data = adjustAccountBalanceSchema.parse(input);
|
|
const adminPayerId = await getAdminPayerId(user.id);
|
|
|
|
if (!adminPayerId) {
|
|
throw new Error(
|
|
"Pessoa com papel administrador não encontrada. Crie uma pessoa admin antes de ajustar o saldo.",
|
|
);
|
|
}
|
|
|
|
let message = "Ajuste de saldo registrado.";
|
|
|
|
await db.transaction(async (tx: typeof db) => {
|
|
const account = await tx.query.financialAccounts.findFirst({
|
|
columns: { id: true },
|
|
where: and(
|
|
eq(financialAccounts.id, data.accountId),
|
|
eq(financialAccounts.userId, user.id),
|
|
),
|
|
});
|
|
|
|
if (!account) {
|
|
throw new Error("Conta não encontrada.");
|
|
}
|
|
|
|
const existing = await tx.query.transactions.findFirst({
|
|
columns: { id: true, amount: true },
|
|
where: and(
|
|
eq(transactions.userId, user.id),
|
|
eq(transactions.accountId, data.accountId),
|
|
eq(transactions.period, data.period),
|
|
eq(transactions.name, ACCOUNT_BALANCE_ADJUSTMENT_NAME),
|
|
),
|
|
});
|
|
|
|
const existingAmount = Number(existing?.amount ?? 0);
|
|
const baseBalance = data.currentBalance - existingAmount;
|
|
const adjustmentAmount =
|
|
Math.round((data.targetBalance - baseBalance) * 100) / 100;
|
|
|
|
if (adjustmentAmount === 0) {
|
|
if (existing) {
|
|
await tx.delete(transactions).where(eq(transactions.id, existing.id));
|
|
message = "Ajuste de saldo removido.";
|
|
} else {
|
|
message = "Nada a ajustar — o saldo já está correto.";
|
|
}
|
|
return;
|
|
}
|
|
|
|
const isExpense = adjustmentAmount < 0;
|
|
const categoryName = isExpense ? "Outras despesas" : "Outras receitas";
|
|
|
|
const category = await tx.query.categories.findFirst({
|
|
columns: { id: true },
|
|
where: and(
|
|
eq(categories.userId, user.id),
|
|
eq(categories.name, categoryName),
|
|
),
|
|
});
|
|
|
|
const amount = formatDecimalForDbRequired(adjustmentAmount);
|
|
const note = `O saldo era ${formatCurrency(baseBalance)} mas o correto é ${formatCurrency(data.targetBalance)}.`;
|
|
|
|
const payload = {
|
|
condition: INITIAL_BALANCE_CONDITION,
|
|
name: ACCOUNT_BALANCE_ADJUSTMENT_NAME,
|
|
paymentMethod: INITIAL_BALANCE_PAYMENT_METHOD,
|
|
note,
|
|
amount,
|
|
purchaseDate: getBusinessTodayDate(),
|
|
transactionType: isExpense
|
|
? ("Despesa" as const)
|
|
: ("Receita" as const),
|
|
period: data.period,
|
|
isSettled: true,
|
|
userId: user.id,
|
|
accountId: data.accountId,
|
|
cardId: null,
|
|
categoryId: category?.id ?? null,
|
|
payerId: adminPayerId,
|
|
};
|
|
|
|
if (existing) {
|
|
await tx
|
|
.update(transactions)
|
|
.set(payload)
|
|
.where(eq(transactions.id, existing.id));
|
|
} else {
|
|
await tx.insert(transactions).values(payload);
|
|
}
|
|
});
|
|
|
|
revalidateForEntity("accounts", user.id);
|
|
revalidateForEntity("transactions", user.id);
|
|
|
|
return { success: true, message };
|
|
} catch (error) {
|
|
return handleActionError(error);
|
|
}
|
|
}
|