feat(lançamentos): ações de converter transações existentes em parceladas e recorrentes

This commit is contained in:
Yuri Argolo
2026-06-13 13:00:44 -03:00
committed by Felipe Coutinho
parent 558197e870
commit 4b5cdf81b8
9 changed files with 629 additions and 0 deletions

View File

@@ -513,11 +513,28 @@ export const toggleSettlementSchema = z.object({
.optional(),
});
export const convertToInstallmentSchema = z.object({
id: uuidSchema("Lançamento"),
});
export const convertToRecurringSchema = z.object({
id: uuidSchema("Lançamento"),
recurrenceCount: z.coerce
.number({ message: "Informe por quantos meses repetir." })
.int()
.min(2, "A recorrência deve ter ao menos dois meses.")
.max(60, "Selecione até 60 meses."),
});
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 type ConvertToInstallmentInput = z.infer<
typeof convertToInstallmentSchema
>;
export type ConvertToRecurringInput = z.infer<typeof convertToRecurringSchema>;
export const revalidate = (userId: string) =>
revalidateForEntity("transactions", userId);

View File

@@ -23,12 +23,17 @@ import {
parseLocalDateString,
} from "@/shared/utils/date";
import { copyAttachmentsForImport } from "../lib/attachment-copy";
import { detectInstallmentFromName } from "../lib/installment-detection";
import { cleanupAttachmentsAfterTransactionDelete } from "./attachments";
import {
buildShares,
buildTransactionRecords,
type ConvertToInstallmentInput,
type ConvertToRecurringInput,
type CreateInput,
centsToDecimalString,
convertToInstallmentSchema,
convertToRecurringSchema,
createSchema,
type DeleteInput,
deleteSchema,
@@ -471,6 +476,330 @@ export async function deleteTransactionAction(
}
}
export async function convertTransactionToInstallmentAction(
input: ConvertToInstallmentInput,
): Promise<ActionResult<{ createdCount: number }>> {
try {
const user = await getUser();
const data = convertToInstallmentSchema.parse(input);
const existing = await db.query.transactions.findFirst({
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.note?.startsWith(ACCOUNT_AUTO_INVOICE_NOTE_PREFIX)) {
return {
success: false,
error: "Pagamentos automáticos de fatura não podem ser convertidos.",
};
}
if (isInitialBalanceTransaction(existing)) {
return {
success: false,
error: "Lançamentos de saldo inicial não podem ser convertidos.",
};
}
if (
existing.paymentMethod !== "Cartão de crédito" ||
!existing.cardId ||
existing.condition !== "À vista"
) {
return {
success: false,
error: "Apenas lançamentos à vista de cartão de crédito podem ser convertidos.",
};
}
if (existing.splitGroupId || existing.isDivided) {
return {
success: false,
error: "Lançamentos divididos ainda não podem ser convertidos em parcelamento.",
};
}
const detected = detectInstallmentFromName(existing.name);
if (!detected) {
return {
success: false,
error:
"Não encontrei um padrão de parcela no nome deste lançamento, como 2/10 ou Parcela 2 de 10.",
};
}
const amountSign: 1 | -1 =
existing.transactionType === "Despesa" ? -1 : 1;
const totalCents =
Math.round(Math.abs(Number(existing.amount)) * 100) *
detected.installmentCount;
const seriesId = randomUUID();
const records = buildTransactionRecords({
data: {
purchaseDate: existing.purchaseDate.toISOString().slice(0, 10),
period: existing.period,
name: detected.name,
transactionType: existing.transactionType as "Receita" | "Despesa",
amount: totalCents / 100,
condition: "Parcelado",
paymentMethod: "Cartão de crédito",
payerId: existing.payerId,
isSplit: false,
accountId: null,
cardId: existing.cardId,
categoryId: existing.categoryId,
note: existing.note,
installmentCount: detected.installmentCount,
startInstallment: detected.currentInstallment,
dueDate: existing.dueDate?.toISOString().slice(0, 10),
isSettled: null,
},
userId: user.id,
period: existing.period,
purchaseDate: existing.purchaseDate,
dueDate: existing.dueDate,
boletoPaymentDate: null,
shares: [{ payerId: existing.payerId, amountCents: totalCents }],
amountSign,
shouldNullifySettled: true,
seriesId,
}).map((record) => ({
...record,
importBatchId: existing.importBatchId,
}));
const currentRow = records[0];
const rowsToInsert = records.slice(1);
if (!currentRow) {
throw new Error("Não foi possível montar o parcelamento.");
}
const periodsToUpdate = records
.map((row) => row.period)
.filter((period): period is string => Boolean(period));
const paidPeriods = await getPaidInvoicePeriods(
user.id,
existing.cardId,
periodsToUpdate,
);
if (paidPeriods.length > 0) {
return {
success: false,
error: `As faturas dos meses ${formatPaidInvoicePeriods(
paidPeriods,
)} já estão pagas. Desfaça o pagamento antes de converter este lançamento.`,
};
}
if (existing.transactionType === "Despesa") {
const limitCheck = await validateCardLimit({
userId: user.id,
cardId: existing.cardId,
addAmount: records.reduce((acc, row) => acc + Math.abs(Number(row.amount)), 0),
excludeTransactionIds: [existing.id],
});
if (!limitCheck.ok) {
return { success: false, error: limitCheck.error };
}
}
await db.transaction(async (tx: typeof db) => {
await tx
.update(transactions)
.set({
condition: currentRow.condition,
name: currentRow.name,
amount: currentRow.amount,
installmentCount: currentRow.installmentCount,
currentInstallment: currentRow.currentInstallment,
recurrenceCount: null,
period: currentRow.period,
dueDate: currentRow.dueDate,
isSettled: null,
seriesId,
})
.where(
and(eq(transactions.id, existing.id), eq(transactions.userId, user.id)),
);
if (rowsToInsert.length > 0) {
await tx.insert(transactions).values(rowsToInsert);
}
});
revalidate(user.id);
return {
success: true,
message: `Lançamento convertido em ${detected.installmentCount} parcelas.`,
data: { createdCount: rowsToInsert.length },
};
} catch (error) {
return handleActionError(error) as ActionResult<{ createdCount: number }>;
}
}
export async function convertTransactionToRecurringAction(
input: ConvertToRecurringInput,
): Promise<ActionResult<{ createdCount: number }>> {
try {
const user = await getUser();
const data = convertToRecurringSchema.parse(input);
const existing = await db.query.transactions.findFirst({
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.note?.startsWith(ACCOUNT_AUTO_INVOICE_NOTE_PREFIX)) {
return {
success: false,
error: "Pagamentos automáticos de fatura não podem ser convertidos.",
};
}
if (isInitialBalanceTransaction(existing)) {
return {
success: false,
error: "Lançamentos de saldo inicial não podem ser convertidos.",
};
}
if (existing.condition !== "À vista") {
return {
success: false,
error: "Apenas lançamentos à vista podem ser convertidos em recorrência.",
};
}
if (existing.splitGroupId || existing.isDivided) {
return {
success: false,
error: "Lançamentos divididos ainda não podem ser convertidos em recorrência.",
};
}
const amountSign: 1 | -1 =
existing.transactionType === "Despesa" ? -1 : 1;
const totalCents = Math.round(Math.abs(Number(existing.amount)) * 100);
const seriesId = randomUUID();
const isCreditCard = existing.paymentMethod === "Cartão de crédito";
const records = buildTransactionRecords({
data: {
purchaseDate: existing.purchaseDate.toISOString().slice(0, 10),
period: existing.period,
name: existing.name,
transactionType: existing.transactionType as "Receita" | "Despesa",
amount: totalCents / 100,
condition: "Recorrente",
paymentMethod: existing.paymentMethod as
| "Pix"
| "Boleto"
| "Dinheiro"
| "Cartão de débito"
| "Cartão de crédito"
| "Pré-Pago | VR/VA"
| "Transferência bancária",
payerId: existing.payerId,
isSplit: false,
accountId: isCreditCard ? null : existing.accountId,
cardId: isCreditCard ? existing.cardId : null,
categoryId: existing.categoryId,
note: existing.note,
recurrenceCount: data.recurrenceCount,
dueDate: existing.dueDate?.toISOString().slice(0, 10),
boletoPaymentDate: existing.boletoPaymentDate
?.toISOString()
.slice(0, 10),
isSettled: existing.isSettled,
},
userId: user.id,
period: existing.period,
purchaseDate: existing.purchaseDate,
dueDate: existing.dueDate,
boletoPaymentDate: existing.boletoPaymentDate,
shares: [{ payerId: existing.payerId, amountCents: totalCents }],
amountSign,
shouldNullifySettled: isCreditCard,
seriesId,
}).map((record) => ({
...record,
importBatchId: existing.importBatchId,
}));
const currentRow = records[0];
const rowsToInsert = records.slice(1);
if (!currentRow) {
throw new Error("Não foi possível montar a recorrência.");
}
if (isCreditCard && existing.cardId) {
const periodsToUpdate = records
.map((row) => row.period)
.filter((period): period is string => Boolean(period));
const paidPeriods = await getPaidInvoicePeriods(
user.id,
existing.cardId,
periodsToUpdate,
);
if (paidPeriods.length > 0) {
return {
success: false,
error: `As faturas dos meses ${formatPaidInvoicePeriods(
paidPeriods,
)} já estão pagas. Desfaça o pagamento antes de converter este lançamento.`,
};
}
}
await db.transaction(async (tx: typeof db) => {
await tx
.update(transactions)
.set({
condition: currentRow.condition,
name: currentRow.name,
amount: currentRow.amount,
recurrenceCount: currentRow.recurrenceCount,
installmentCount: null,
currentInstallment: null,
period: currentRow.period,
purchaseDate: currentRow.purchaseDate,
dueDate: currentRow.dueDate,
isSettled: currentRow.isSettled,
boletoPaymentDate: currentRow.boletoPaymentDate,
seriesId,
})
.where(
and(eq(transactions.id, existing.id), eq(transactions.userId, user.id)),
);
if (rowsToInsert.length > 0) {
await tx.insert(transactions).values(rowsToInsert);
}
});
revalidate(user.id);
return {
success: true,
message: `Lançamento convertido em recorrência de ${data.recurrenceCount} meses.`,
data: { createdCount: rowsToInsert.length },
};
} catch (error) {
return handleActionError(error) as ActionResult<{ createdCount: number }>;
}
}
export async function updateTransactionSplitPairAction(
input: UpdateInput,
): Promise<ActionResult> {