mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-07-09 11:26:00 +00:00
feat(lançamentos): ações de converter transações existentes em parceladas e recorrentes
This commit is contained in:
committed by
Felipe Coutinho
parent
558197e870
commit
4b5cdf81b8
@@ -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);
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user