Remove infraestrutura de series recorrentes

This commit is contained in:
Felipe Coutinho
2026-03-14 18:35:28 +00:00
parent a143f70269
commit 9fb3cc5ecd
20 changed files with 71 additions and 3138 deletions

View File

@@ -3,13 +3,11 @@
import { randomUUID } from "node:crypto";
import { and, asc, eq, inArray, sql } from "drizzle-orm";
import { z } from "zod";
import type { RecurringSeriesTemplate } from "@/db/schema";
import {
cards,
categories,
financialAccounts,
payers,
recurringSeries,
transactions,
} from "@/db/schema";
import {
@@ -221,6 +219,22 @@ const refineLancamento = (
});
}
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.condition === "Parcelado") {
if (!data.installmentCount) {
ctx.addIssue({
@@ -518,23 +532,33 @@ const buildLancamentoRecords = ({
}
if (data.condition === "Recorrente") {
// For the new recurring model, only create 1 row (the current month)
// Future rows will be generated lazily by generateRecurringTransactions
shares.forEach((share) => {
const settled = resolveSettledValue(0);
records.push({
...basePayload,
amount: centsToDecimalString(share.amountCents * amountSign),
payerId: share.payerId,
purchaseDate,
period,
isSettled: settled,
recurrenceCount: null,
dueDate,
boletoPaymentDate:
data.paymentMethod === "Boleto" && settled ? boletoPaymentDate : null,
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),
payerId: share.payerId,
purchaseDate: recurrencePurchaseDate,
period: recurrencePeriod,
isSettled: settled,
recurrenceCount: recurrenceTotal,
dueDate: recurrenceDueDate,
boletoPaymentDate:
data.paymentMethod === "Boleto" && settled
? boletoPaymentDate
: null,
});
});
});
}
return records;
}
@@ -661,42 +685,7 @@ export async function createTransactionAction(
throw new Error("Não foi possível criar os lançamentos solicitados.");
}
await db.transaction(async (tx: typeof db) => {
// If creating a recurring series, insert the series row first
if (data.condition === "Recorrente" && seriesId) {
const templateData: RecurringSeriesTemplate = {
name: data.name,
amount: centsToDecimalString(
Math.round(Math.abs(data.amount) * 100) *
(data.transactionType === "Despesa" ? -1 : 1),
),
transactionType: data.transactionType,
paymentMethod: data.paymentMethod,
categoryId: data.categoryId ?? null,
accountId: data.accountId ?? null,
cardId: data.cardId ?? null,
payerId: data.payerId ?? null,
note: data.note ?? null,
condition: "Recorrente",
};
await tx.insert(recurringSeries).values({
id: seriesId,
userId: user.id,
status: "active",
dayOfMonth: purchaseDate.getDate(),
lastGeneratedPeriod: period,
templateData,
});
// Link lancamento records to the recurring series
for (const record of records) {
record.recurringSeriesId = seriesId;
}
}
await tx.insert(transactions).values(records);
});
await db.insert(transactions).values(records);
const notificationEntries = buildEntriesByPayer(
records.map((record) => ({

View File

@@ -101,14 +101,26 @@ export function ConditionSection({
{showRecurrence ? (
<div className="space-y-1 w-full md:w-1/2">
<Label htmlFor="recurrenceInfo">Recorrência</Label>
<p
id="recurrenceInfo"
className="text-xs text-muted-foreground rounded-md border border-dashed border-border p-2.5"
<Label htmlFor="recurrenceCount">Repetirá por</Label>
<Select
value={formState.recurrenceCount}
onValueChange={(value) => onFieldChange("recurrenceCount", value)}
>
Este lançamento será repetido todo mês automaticamente até ser
pausado ou cancelado.
</p>
<SelectTrigger id="recurrenceCount" className="w-full">
<SelectValue placeholder="Selecione">
{formState.recurrenceCount
? `${formState.recurrenceCount} meses`
: null}
</SelectValue>
</SelectTrigger>
<SelectContent>
{[...Array(47)].map((_, index) => (
<SelectItem key={index + 2} value={String(index + 2)}>
{index + 2} meses
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : null}
</div>

View File

@@ -1,5 +1,5 @@
"use client";
import { RiAddLine } from "@remixicon/react";
import { RiArrowDropDownLine } from "@remixicon/react";
import { useEffect, useMemo, useState, useTransition } from "react";
import { toast } from "sonner";
import {
@@ -292,7 +292,10 @@ export function TransactionDialog({
formState.condition === "Parcelado" && formState.installmentCount
? Number(formState.installmentCount)
: undefined,
recurrenceCount: undefined,
recurrenceCount:
formState.condition === "Recorrente" && formState.recurrenceCount
? Number(formState.recurrenceCount)
: undefined,
dueDate:
formState.paymentMethod === "Boleto" && formState.dueDate
? formState.dueDate
@@ -375,7 +378,7 @@ export function TransactionDialog({
const title =
mode === "create"
? isImportMode
? "Importar para Minha FinancialAccount"
? "Importar para Minha Conta"
: isCopyMode
? "Copiar lançamento"
: isNewWithType
@@ -476,7 +479,7 @@ export function TransactionDialog({
}
>
<CollapsibleTrigger className="flex w-full items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer [&[data-state=open]>svg]:rotate-180 mt-4">
<RiAddLine className="text-primary size-4 transition-transform duration-200" />
<RiArrowDropDownLine className="text-primary size-4 transition-transform duration-200" />
Condições e anotações
</CollapsibleTrigger>
<CollapsibleContent className="space-y-3 pt-3">