Files
openmonetis/app/(dashboard)/lancamentos/actions.ts
Felipe Coutinho 3ce8541a56 fix: corrige a grafia de "OpenSheets" para "Opensheets"
Este commit altera todas as ocorrências de "OpenSheets" para "Opensheets"
em arquivos de layout, README, componentes e notificações, garantindo
consistência na nomenclatura do projeto.
2025-11-22 20:29:25 -03:00

1408 lines
39 KiB
TypeScript

"use server";
import { contas, lancamentos } from "@/db/schema";
import {
INITIAL_BALANCE_CONDITION,
INITIAL_BALANCE_NOTE,
INITIAL_BALANCE_PAYMENT_METHOD,
INITIAL_BALANCE_TRANSACTION_TYPE,
} from "@/lib/accounts/constants";
import { handleActionError, revalidateForEntity } from "@/lib/actions/helpers";
import type { ActionResult } from "@/lib/actions/types";
import { db } from "@/lib/db";
import { getUser } from "@/lib/auth/server";
import {
LANCAMENTO_CONDITIONS,
LANCAMENTO_PAYMENT_METHODS,
LANCAMENTO_TRANSACTION_TYPES,
} from "@/lib/lancamentos/constants";
import {
buildEntriesByPagador,
sendPagadorAutoEmails,
} from "@/lib/pagadores/notifications";
import { noteSchema, uuidSchema } from "@/lib/schemas/common";
import { formatDecimalForDbRequired } from "@/lib/utils/currency";
import { getTodayDateString } from "@/lib/utils/date";
import { and, asc, desc, eq, gte, inArray, sql } from "drizzle-orm";
import { randomUUID } from "node:crypto";
import { z } from "zod";
const resolvePeriod = (purchaseDate: string, period?: string | null) => {
if (period && /^\d{4}-\d{2}$/.test(period)) {
return period;
}
const date = new Date(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}`;
};
const getTodayDate = () => new Date(getTodayDateString());
const baseFields = z.object({
purchaseDate: z
.string({ message: "Informe a data da transação." })
.trim()
.refine((value) => !Number.isNaN(new Date(value).getTime()), {
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(LANCAMENTO_TRANSACTION_TYPES, {
message: "Selecione um tipo de transação válido.",
})
.default(LANCAMENTO_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(LANCAMENTO_CONDITIONS, {
message: "Selecione uma condição válida.",
}),
paymentMethod: z.enum(LANCAMENTO_PAYMENT_METHODS, {
message: "Selecione uma forma de pagamento válida.",
}),
pagadorId: uuidSchema("Pagador").nullable().optional(),
secondaryPagadorId: uuidSchema("Pagador secundário").optional(),
isSplit: z.boolean().optional().default(false),
contaId: uuidSchema("Conta").nullable().optional(),
cartaoId: uuidSchema("Cartão").nullable().optional(),
categoriaId: uuidSchema("Categoria").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 || !Number.isNaN(new Date(value).getTime()), {
message: "Informe uma data de vencimento válida.",
})
.optional(),
boletoPaymentDate: z
.string()
.trim()
.refine((value) => !value || !Number.isNaN(new Date(value).getTime()), {
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.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.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.isSplit) {
if (!data.pagadorId) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["pagadorId"],
message: "Selecione o pagador principal para dividir o lançamento.",
});
}
if (!data.secondaryPagadorId) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["secondaryPagadorId"],
message: "Selecione o pagador secundário para dividir o lançamento.",
});
} else if (data.pagadorId && data.secondaryPagadorId === data.pagadorId) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["secondaryPagadorId"],
message: "Escolha um pagador diferente para dividir o lançamento.",
});
}
}
};
const createSchema = baseFields.superRefine(refineLancamento);
const updateSchema = baseFields
.extend({
id: uuidSchema("Lançamento"),
})
.superRefine(refineLancamento);
const deleteSchema = z.object({
id: uuidSchema("Lançamento"),
});
const toggleSettlementSchema = z.object({
id: uuidSchema("Lançamento"),
value: z.boolean({
message: "Informe o status de pagamento.",
}),
});
type BaseInput = z.infer<typeof baseFields>;
type CreateInput = z.infer<typeof createSchema>;
type UpdateInput = z.infer<typeof updateSchema>;
type DeleteInput = z.infer<typeof deleteSchema>;
type ToggleSettlementInput = z.infer<typeof toggleSettlementSchema>;
const revalidate = () => revalidateForEntity("lancamentos");
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 "Opensheets";
};
type InitialCandidate = {
note: string | null;
transactionType: string | null;
condition: string | null;
paymentMethod: string | null;
};
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;
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)
);
};
const addMonthsToPeriod = (period: string, offset: number) => {
const [yearStr, monthStr] = period.split("-");
const baseYear = Number(yearStr);
const baseMonth = Number(monthStr);
if (!baseYear || !baseMonth) {
throw new Error("Período inválido.");
}
const date = new Date(baseYear, baseMonth - 1, 1);
date.setMonth(date.getMonth() + offset);
const nextYear = date.getFullYear();
const nextMonth = String(date.getMonth() + 1).padStart(2, "0");
return `${nextYear}-${nextMonth}`;
};
const addMonthsToDate = (value: Date, offset: number) => {
const result = new Date(value);
const originalDay = result.getDate();
result.setDate(1);
result.setMonth(result.getMonth() + offset);
const lastDay = new Date(
result.getFullYear(),
result.getMonth() + 1,
0
).getDate();
result.setDate(Math.min(originalDay, lastDay));
return result;
};
type Share = {
pagadorId: string | null;
amountCents: number;
};
const buildShares = ({
totalCents,
pagadorId,
isSplit,
secondaryPagadorId,
}: {
totalCents: number;
pagadorId: string | null;
isSplit: boolean;
secondaryPagadorId?: string;
}): Share[] => {
if (isSplit) {
if (!pagadorId || !secondaryPagadorId) {
throw new Error("Configuração de divisão inválida para o lançamento.");
}
const [primaryAmount, secondaryAmount] = splitAmount(totalCents, 2);
return [
{ pagadorId, amountCents: primaryAmount },
{ pagadorId: secondaryPagadorId, amountCents: secondaryAmount },
];
}
return [{ pagadorId, amountCents: totalCents }];
};
type BuildLancamentoRecordsParams = {
data: BaseInput;
userId: string;
period: string;
purchaseDate: Date;
dueDate: Date | null;
boletoPaymentDate: Date | null;
shares: Share[];
amountSign: 1 | -1;
shouldNullifySettled: boolean;
seriesId: string | null;
};
type LancamentoInsert = typeof lancamentos.$inferInsert;
const buildLancamentoRecords = ({
data,
userId,
period,
purchaseDate,
dueDate,
boletoPaymentDate,
shares,
amountSign,
shouldNullifySettled,
seriesId,
}: BuildLancamentoRecordsParams): LancamentoInsert[] => {
const records: LancamentoInsert[] = [];
const basePayload = {
name: data.name,
transactionType: data.transactionType,
condition: data.condition,
paymentMethod: data.paymentMethod,
note: data.note ?? null,
contaId: data.contaId ?? null,
cartaoId: data.cartaoId ?? null,
categoriaId: data.categoriaId ?? 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 installmentPurchaseDate = addMonthsToDate(
purchaseDate,
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),
pagadorId: share.pagadorId,
purchaseDate: installmentPurchaseDate,
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),
pagadorId: share.pagadorId,
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),
pagadorId: share.pagadorId,
purchaseDate,
period,
isSettled: settled,
dueDate,
boletoPaymentDate:
data.paymentMethod === "Boleto" && settled ? boletoPaymentDate : null,
});
});
return records;
};
export async function createLancamentoAction(
input: CreateInput
): Promise<ActionResult> {
try {
const user = await getUser();
const data = createSchema.parse(input);
const period = resolvePeriod(data.purchaseDate, data.period);
const purchaseDate = new Date(data.purchaseDate);
const dueDate = data.dueDate ? new Date(data.dueDate) : null;
const shouldSetBoletoPaymentDate =
data.paymentMethod === "Boleto" && (data.isSettled ?? false);
const boletoPaymentDate = shouldSetBoletoPaymentDate
? data.boletoPaymentDate
? new Date(data.boletoPaymentDate)
: getTodayDate()
: 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,
pagadorId: data.pagadorId ?? null,
isSplit: data.isSplit ?? false,
secondaryPagadorId: data.secondaryPagadorId,
});
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.transaction(async (tx: typeof db) => {
await tx.insert(lancamentos).values(records);
});
const notificationEntries = buildEntriesByPagador(
records.map((record) => ({
pagadorId: record.pagadorId ?? 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 sendPagadorAutoEmails({
userLabel: resolveUserLabel(user),
action: "created",
entriesByPagador: notificationEntries,
});
}
revalidate();
return { success: true, message: "Lançamento criado com sucesso." };
} catch (error) {
return handleActionError(error);
}
}
export async function updateLancamentoAction(
input: UpdateInput
): Promise<ActionResult> {
try {
const user = await getUser();
const data = updateSchema.parse(input);
const existing = await db.query.lancamentos.findFirst({
columns: {
id: true,
note: true,
transactionType: true,
condition: true,
paymentMethod: true,
contaId: true,
categoriaId: true,
},
where: and(eq(lancamentos.id, data.id), eq(lancamentos.userId, user.id)),
with: {
categoria: {
columns: {
name: true,
},
},
},
});
if (!existing) {
return { success: false, error: "Lançamento não encontrado." };
}
// Bloquear edição de lançamentos com categorias protegidas
// Nota: "Transferência interna" foi removida para permitir correção de valores
const categoriasProtegidasEdicao = ["Saldo inicial", "Pagamentos"];
if (
existing.categoria?.name &&
categoriasProtegidasEdicao.includes(existing.categoria.name)
) {
return {
success: false,
error: `Lançamentos com a categoria '${existing.categoria.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
? new Date(data.boletoPaymentDate)
: getTodayDate()
: null;
await db
.update(lancamentos)
.set({
name: data.name,
purchaseDate: new Date(data.purchaseDate),
transactionType: data.transactionType,
amount: normalizedAmount,
condition: data.condition,
paymentMethod: data.paymentMethod,
pagadorId: data.pagadorId ?? null,
contaId: data.contaId ?? null,
cartaoId: data.cartaoId ?? null,
categoriaId: data.categoriaId ?? null,
note: data.note ?? null,
isSettled: normalizedSettled,
installmentCount: data.installmentCount ?? null,
recurrenceCount: data.recurrenceCount ?? null,
dueDate: data.dueDate ? new Date(data.dueDate) : null,
boletoPaymentDate: boletoPaymentDateValue,
period,
})
.where(and(eq(lancamentos.id, data.id), eq(lancamentos.userId, user.id)));
if (isInitialBalanceLancamento(existing) && existing?.contaId) {
const updatedInitialBalance = formatDecimalForDbRequired(
Math.abs(data.amount ?? 0)
);
await db
.update(contas)
.set({ initialBalance: updatedInitialBalance })
.where(
and(eq(contas.id, existing.contaId), eq(contas.userId, user.id))
);
}
revalidate();
return { success: true, message: "Lançamento atualizado com sucesso." };
} catch (error) {
return handleActionError(error);
}
}
export async function deleteLancamentoAction(
input: DeleteInput
): Promise<ActionResult> {
try {
const user = await getUser();
const data = deleteSchema.parse(input);
const existing = await db.query.lancamentos.findFirst({
columns: {
id: true,
name: true,
pagadorId: true,
amount: true,
transactionType: true,
paymentMethod: true,
condition: true,
purchaseDate: true,
period: true,
note: true,
categoriaId: true,
},
where: and(eq(lancamentos.id, data.id), eq(lancamentos.userId, user.id)),
with: {
categoria: {
columns: {
name: true,
},
},
},
});
if (!existing) {
return { success: false, error: "Lançamento não encontrado." };
}
// Bloquear remoção de lançamentos com categorias protegidas
// Nota: "Transferência interna" foi removida para permitir correção/exclusão
const categoriasProtegidasRemocao = ["Saldo inicial", "Pagamentos"];
if (
existing.categoria?.name &&
categoriasProtegidasRemocao.includes(existing.categoria.name)
) {
return {
success: false,
error: `Lançamentos com a categoria '${existing.categoria.name}' não podem ser removidos.`,
};
}
await db
.delete(lancamentos)
.where(and(eq(lancamentos.id, data.id), eq(lancamentos.userId, user.id)));
if (existing.pagadorId) {
const notificationEntries = buildEntriesByPagador([
{
pagadorId: existing.pagadorId,
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 sendPagadorAutoEmails({
userLabel: resolveUserLabel(user),
action: "deleted",
entriesByPagador: notificationEntries,
});
}
revalidate();
return { success: true, message: "Lançamento removido com sucesso." };
} catch (error) {
return handleActionError(error);
}
}
export async function toggleLancamentoSettlementAction(
input: ToggleSettlementInput
): Promise<ActionResult> {
try {
const user = await getUser();
const data = toggleSettlementSchema.parse(input);
const existing = await db.query.lancamentos.findFirst({
columns: { id: true, paymentMethod: true },
where: and(eq(lancamentos.id, data.id), eq(lancamentos.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
? getTodayDate()
: null
: null;
await db
.update(lancamentos)
.set({
isSettled: data.value,
boletoPaymentDate,
})
.where(and(eq(lancamentos.id, data.id), eq(lancamentos.userId, user.id)));
revalidate();
return {
success: true,
message: data.value
? "Lançamento marcado como pago."
: "Pagamento desfeito com sucesso.",
};
} catch (error) {
return handleActionError(error);
}
}
const deleteBulkSchema = z.object({
id: uuidSchema("Lançamento"),
scope: z.enum(["current", "future", "all"], {
message: "Escopo de ação inválido.",
}),
});
type DeleteBulkInput = z.infer<typeof deleteBulkSchema>;
export async function deleteLancamentoBulkAction(
input: DeleteBulkInput
): Promise<ActionResult> {
try {
const user = await getUser();
const data = deleteBulkSchema.parse(input);
const existing = await db.query.lancamentos.findFirst({
columns: {
id: true,
name: true,
seriesId: true,
period: true,
condition: true,
},
where: and(eq(lancamentos.id, data.id), eq(lancamentos.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(lancamentos)
.where(
and(eq(lancamentos.id, data.id), eq(lancamentos.userId, user.id))
);
revalidate();
return { success: true, message: "Lançamento removido com sucesso." };
}
if (data.scope === "future") {
await db
.delete(lancamentos)
.where(
and(
eq(lancamentos.seriesId, existing.seriesId),
eq(lancamentos.userId, user.id),
sql`${lancamentos.period} >= ${existing.period}`
)
);
revalidate();
return {
success: true,
message: "Lançamentos removidos com sucesso.",
};
}
if (data.scope === "all") {
await db
.delete(lancamentos)
.where(
and(
eq(lancamentos.seriesId, existing.seriesId),
eq(lancamentos.userId, user.id)
)
);
revalidate();
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);
}
}
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."),
categoriaId: uuidSchema("Categoria").nullable().optional(),
note: noteSchema,
pagadorId: uuidSchema("Pagador").nullable().optional(),
contaId: uuidSchema("Conta").nullable().optional(),
cartaoId: 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 || !Number.isNaN(new Date(value).getTime()), {
message: "Informe uma data de vencimento válida.",
})
.optional()
.nullable(),
boletoPaymentDate: z
.string()
.trim()
.refine((value) => !value || !Number.isNaN(new Date(value).getTime()), {
message: "Informe uma data de pagamento válida.",
})
.optional()
.nullable(),
});
type UpdateBulkInput = z.infer<typeof updateBulkSchema>;
export async function updateLancamentoBulkAction(
input: UpdateBulkInput
): Promise<ActionResult> {
try {
const user = await getUser();
const data = updateBulkSchema.parse(input);
const existing = await db.query.lancamentos.findFirst({
columns: {
id: true,
name: true,
seriesId: true,
period: true,
condition: true,
transactionType: true,
purchaseDate: true,
},
where: and(eq(lancamentos.id, data.id), eq(lancamentos.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,
categoriaId: data.categoriaId ?? null,
note: data.note ?? null,
pagadorId: data.pagadorId ?? null,
contaId: data.contaId ?? null,
cartaoId: data.cartaoId ?? 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
? new Date(data.dueDate)
: hasDueDateUpdate
? null
: undefined;
const baseBoletoPaymentDate =
hasBoletoPaymentDateUpdate && data.boletoPaymentDate
? new Date(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 applyUpdates = async (
records: Array<{ id: string; purchaseDate: Date | null }>
) => {
if (records.length === 0) {
return;
}
await db.transaction(async (tx: typeof db) => {
for (const record of records) {
const perRecordPayload: Record<string, unknown> = {
...baseUpdatePayload,
};
const dueDateForRecord = buildDueDateForRecord(record.purchaseDate);
if (dueDateForRecord !== undefined) {
perRecordPayload.dueDate = dueDateForRecord;
}
if (hasBoletoPaymentDateUpdate) {
perRecordPayload.boletoPaymentDate = baseBoletoPaymentDate ?? null;
}
await tx
.update(lancamentos)
.set(perRecordPayload)
.where(
and(
eq(lancamentos.id, record.id),
eq(lancamentos.userId, user.id)
)
);
}
});
};
if (data.scope === "current") {
await applyUpdates([
{
id: data.id,
purchaseDate: existing.purchaseDate ?? null,
},
]);
revalidate();
return { success: true, message: "Lançamento atualizado com sucesso." };
}
if (data.scope === "future") {
const futureLancamentos = await db.query.lancamentos.findMany({
columns: {
id: true,
purchaseDate: true,
},
where: and(
eq(lancamentos.seriesId, existing.seriesId),
eq(lancamentos.userId, user.id),
sql`${lancamentos.period} >= ${existing.period}`
),
orderBy: asc(lancamentos.purchaseDate),
});
await applyUpdates(
futureLancamentos.map((item) => ({
id: item.id,
purchaseDate: item.purchaseDate ?? null,
}))
);
revalidate();
return {
success: true,
message: "Lançamentos atualizados com sucesso.",
};
}
if (data.scope === "all") {
const allLancamentos = await db.query.lancamentos.findMany({
columns: {
id: true,
purchaseDate: true,
},
where: and(
eq(lancamentos.seriesId, existing.seriesId),
eq(lancamentos.userId, user.id)
),
orderBy: asc(lancamentos.purchaseDate),
});
await applyUpdates(
allLancamentos.map((item) => ({
id: item.id,
purchaseDate: item.purchaseDate ?? null,
}))
);
revalidate();
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);
}
}
// Mass Add Schema
const massAddTransactionSchema = z.object({
purchaseDate: z
.string({ message: "Informe a data da transação." })
.trim()
.refine((value) => !Number.isNaN(new Date(value).getTime()), {
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."),
categoriaId: uuidSchema("Categoria").nullable().optional(),
});
const massAddSchema = z.object({
fixedFields: z.object({
transactionType: z.enum(LANCAMENTO_TRANSACTION_TYPES).optional(),
pagadorId: uuidSchema("Pagador").nullable().optional(),
paymentMethod: z.enum(LANCAMENTO_PAYMENT_METHODS).optional(),
condition: z.enum(LANCAMENTO_CONDITIONS).optional(),
period: z
.string()
.trim()
.regex(/^(\d{4})-(\d{2})$/, {
message: "Selecione um período válido.",
})
.optional(),
contaId: uuidSchema("Conta").nullable().optional(),
cartaoId: uuidSchema("Cartão").nullable().optional(),
}),
transactions: z
.array(massAddTransactionSchema)
.min(1, "Adicione pelo menos uma transação."),
});
type MassAddInput = z.infer<typeof massAddSchema>;
export async function createMassLancamentosAction(
input: MassAddInput
): Promise<ActionResult> {
try {
const user = await getUser();
const data = massAddSchema.parse(input);
// Default values for non-fixed fields
const defaultTransactionType = LANCAMENTO_TRANSACTION_TYPES[0];
const defaultCondition = LANCAMENTO_CONDITIONS[0];
const defaultPaymentMethod = LANCAMENTO_PAYMENT_METHODS[0];
const allRecords: LancamentoInsert[] = [];
const notificationData: Array<{
pagadorId: 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;
}> = [];
// Process each transaction
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 pagadorId = data.fixedFields.pagadorId ?? null;
const contaId =
paymentMethod === "Cartão de crédito"
? null
: data.fixedFields.contaId ?? null;
const cartaoId =
paymentMethod === "Cartão de crédito"
? data.fixedFields.cartaoId ?? null
: null;
const categoriaId = transaction.categoriaId ?? null;
const period =
data.fixedFields.period ?? resolvePeriod(transaction.purchaseDate);
const purchaseDate = new Date(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: LancamentoInsert = {
name: transaction.name,
purchaseDate,
period,
transactionType,
amount,
condition,
paymentMethod,
pagadorId,
contaId,
cartaoId,
categoriaId,
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({
pagadorId,
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.");
}
// Insert all records in a single transaction
await db.transaction(async (tx: typeof db) => {
await tx.insert(lancamentos).values(allRecords);
});
// Send notifications
const notificationEntries = buildEntriesByPagador(notificationData);
if (notificationEntries.size > 0) {
await sendPagadorAutoEmails({
userLabel: resolveUserLabel(user),
action: "created",
entriesByPagador: notificationEntries,
});
}
revalidate();
const count = allRecords.length;
return {
success: true,
message: `${count} ${
count === 1 ? "lançamento criado" : "lançamentos criados"
} com sucesso.`,
};
} catch (error) {
return handleActionError(error);
}
}
// Delete multiple lancamentos at once
const deleteMultipleSchema = z.object({
ids: z
.array(uuidSchema("Lançamento"))
.min(1, "Selecione pelo menos um lançamento."),
});
type DeleteMultipleInput = z.infer<typeof deleteMultipleSchema>;
export async function deleteMultipleLancamentosAction(
input: DeleteMultipleInput
): Promise<ActionResult> {
try {
const user = await getUser();
const data = deleteMultipleSchema.parse(input);
// Fetch all lancamentos to be deleted
const existing = await db.query.lancamentos.findMany({
columns: {
id: true,
name: true,
pagadorId: true,
amount: true,
transactionType: true,
paymentMethod: true,
condition: true,
purchaseDate: true,
period: true,
note: true,
},
where: and(
inArray(lancamentos.id, data.ids),
eq(lancamentos.userId, user.id)
),
});
if (existing.length === 0) {
return { success: false, error: "Nenhum lançamento encontrado." };
}
// Delete all lancamentos
await db
.delete(lancamentos)
.where(
and(inArray(lancamentos.id, data.ids), eq(lancamentos.userId, user.id))
);
// Send notifications
const notificationData = existing
.filter(
(
item
): item is typeof item & {
pagadorId: NonNullable<typeof item.pagadorId>;
} => Boolean(item.pagadorId)
)
.map((item) => ({
pagadorId: item.pagadorId,
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 = buildEntriesByPagador(notificationData);
await sendPagadorAutoEmails({
userLabel: resolveUserLabel(user),
action: "deleted",
entriesByPagador: notificationEntries,
});
}
revalidate();
const count = existing.length;
return {
success: true,
message: `${count} ${
count === 1 ? "lançamento removido" : "lançamentos removidos"
} com sucesso.`,
};
} catch (error) {
return handleActionError(error);
}
}
// Get unique establishment names from the last 3 months
export async function getRecentEstablishmentsAction(): Promise<string[]> {
try {
const user = await getUser();
// Calculate date 3 months ago
const threeMonthsAgo = new Date();
threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3);
// Fetch establishment names from the last 3 months
const results = await db
.select({ name: lancamentos.name })
.from(lancamentos)
.where(
and(
eq(lancamentos.userId, user.id),
gte(lancamentos.purchaseDate, threeMonthsAgo)
)
)
.orderBy(desc(lancamentos.purchaseDate));
// Remove duplicates and filter empty names
const uniqueNames = Array.from(
new Set(
results
.map((r) => r.name)
.filter(
(name): name is string =>
name != null &&
name.trim().length > 0 &&
!name.toLowerCase().startsWith("pagamento fatura")
)
)
);
// Return top 50 most recent unique establishments
return uniqueNames.slice(0, 100);
} catch (error) {
console.error("Error fetching recent establishments:", error);
return [];
}
}