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

@@ -1,7 +1,7 @@
import type { Config } from "drizzle-kit"; import type { Config } from "drizzle-kit";
export default { export default {
schema: "./db/schema.ts", schema: "./src/db/schema.ts",
out: "./drizzle", out: "./drizzle",
dialect: "postgresql", dialect: "postgresql",
dbCredentials: { dbCredentials: {

View File

@@ -1,15 +0,0 @@
CREATE TABLE "recurring_series" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" text NOT NULL,
"status" text DEFAULT 'active' NOT NULL,
"day_of_month" smallint NOT NULL,
"last_generated_period" text NOT NULL,
"template_data" jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "lancamentos" ADD COLUMN "recurring_series_id" uuid;--> statement-breakpoint
ALTER TABLE "recurring_series" ADD CONSTRAINT "recurring_series_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "recurring_series_user_id_status_idx" ON "recurring_series" USING btree ("user_id","status");--> statement-breakpoint
ALTER TABLE "lancamentos" ADD CONSTRAINT "lancamentos_recurring_series_id_recurring_series_id_fk" FOREIGN KEY ("recurring_series_id") REFERENCES "public"."recurring_series"("id") ON DELETE set null ON UPDATE no action;

File diff suppressed because it is too large Load Diff

View File

@@ -134,13 +134,6 @@
"when": 1773020417482, "when": 1773020417482,
"tag": "0018_rainy_epoch", "tag": "0018_rainy_epoch",
"breakpoints": true "breakpoints": true
},
{
"idx": 19,
"version": "7",
"when": 1773265586360,
"tag": "0019_parched_mephistopheles",
"breakpoints": true
} }
] ]
} }

View File

@@ -3,7 +3,6 @@ import { DashboardMetricsCards } from "@/features/dashboard/components/dashboard
import { DashboardWelcome } from "@/features/dashboard/components/dashboard-welcome"; import { DashboardWelcome } from "@/features/dashboard/components/dashboard-welcome";
import { fetchDashboardData } from "@/features/dashboard/fetch-dashboard-data"; import { fetchDashboardData } from "@/features/dashboard/fetch-dashboard-data";
import { fetchUserDashboardPreferences } from "@/features/dashboard/preferences-queries"; import { fetchUserDashboardPreferences } from "@/features/dashboard/preferences-queries";
import { triggerRecurringGeneration } from "@/features/recurring/trigger-recurring-generation";
import { import {
buildOptionSets, buildOptionSets,
buildSluggedFilters, buildSluggedFilters,
@@ -25,7 +24,6 @@ type PageProps = {
export default async function Page({ searchParams }: PageProps) { export default async function Page({ searchParams }: PageProps) {
const user = await getUser(); const user = await getUser();
await triggerRecurringGeneration(user.id);
const resolvedSearchParams = searchParams ? await searchParams : undefined; const resolvedSearchParams = searchParams ? await searchParams : undefined;
const periodoParam = getSingleParam(resolvedSearchParams, "periodo"); const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
const { period: selectedPeriod } = parsePeriodParam(periodoParam); const { period: selectedPeriod } = parsePeriodParam(periodoParam);

View File

@@ -1,11 +1,10 @@
import { triggerRecurringGeneration } from "@/features/recurring/trigger-recurring-generation";
import { fetchUserPreferences } from "@/features/settings/queries"; import { fetchUserPreferences } from "@/features/settings/queries";
import { TransactionsPage } from "@/features/transactions/components/page/transactions-page"; import { TransactionsPage } from "@/features/transactions/components/page/transactions-page";
import { import {
buildTransactionWhere,
buildOptionSets, buildOptionSets,
buildSluggedFilters, buildSluggedFilters,
buildSlugMaps, buildSlugMaps,
buildTransactionWhere,
extractTransactionSearchFilters, extractTransactionSearchFilters,
getSingleParam, getSingleParam,
mapTransactionsData, mapTransactionsData,
@@ -28,7 +27,6 @@ type PageProps = {
export default async function Page({ searchParams }: PageProps) { export default async function Page({ searchParams }: PageProps) {
const userId = await getUserId(); const userId = await getUserId();
await triggerRecurringGeneration(userId);
const resolvedSearchParams = searchParams ? await searchParams : undefined; const resolvedSearchParams = searchParams ? await searchParams : undefined;
const periodoParamRaw = getSingleParam(resolvedSearchParams, "periodo"); const periodoParamRaw = getSingleParam(resolvedSearchParams, "periodo");

View File

@@ -562,55 +562,6 @@ export const installmentAnticipations = pgTable(
}), }),
); );
// ===================== RECURRING SERIES =====================
export type RecurringSeriesTemplate = {
name: string;
amount: string;
transactionType: string;
paymentMethod: string;
categoryId: string | null;
accountId: string | null;
cardId: string | null;
payerId: string | null;
note: string | null;
condition: string;
};
export const recurringSeries = pgTable(
"recurring_series",
{
id: uuid("id").primaryKey().default(sql`gen_random_uuid()`),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
status: text("status").notNull().default("active"), // "active" | "paused" | "cancelled"
dayOfMonth: smallint("day_of_month").notNull(),
lastGeneratedPeriod: text("last_generated_period").notNull(), // YYYY-MM
templateData: jsonb("template_data")
.notNull()
.$type<RecurringSeriesTemplate>(),
createdAt: timestamp("created_at", {
mode: "date",
withTimezone: true,
})
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", {
mode: "date",
withTimezone: true,
})
.notNull()
.defaultNow(),
},
(table) => ({
userIdStatusIdx: index("recurring_series_user_id_status_idx").on(
table.userId,
table.status,
),
}),
);
// ===================== TRANSACTIONS ===================== // ===================== TRANSACTIONS =====================
export const transactions = pgTable( export const transactions = pgTable(
@@ -664,10 +615,6 @@ export const transactions = pgTable(
}), }),
seriesId: uuid("series_id"), seriesId: uuid("series_id"),
transferId: uuid("transfer_id"), transferId: uuid("transfer_id"),
recurringSeriesId: uuid("recurring_series_id").references(
() => recurringSeries.id,
{ onDelete: "set null" },
),
}, },
(table) => ({ (table) => ({
// Índice composto mais importante: userId + period (usado em quase todas as queries do dashboard) // Índice composto mais importante: userId + period (usado em quase todas as queries do dashboard)
@@ -722,7 +669,6 @@ export const userRelations = relations(user, ({ many, one }) => ({
installmentAnticipations: many(installmentAnticipations), installmentAnticipations: many(installmentAnticipations),
apiTokens: many(apiTokens), apiTokens: many(apiTokens),
inboxItems: many(inboxItems), inboxItems: many(inboxItems),
recurringSeries: many(recurringSeries),
})); }));
export const accountRelations = relations(account, ({ one }) => ({ export const accountRelations = relations(account, ({ one }) => ({
@@ -876,23 +822,8 @@ export const transactionsRelations = relations(transactions, ({ one }) => ({
fields: [transactions.anticipationId], fields: [transactions.anticipationId],
references: [installmentAnticipations.id], references: [installmentAnticipations.id],
}), }),
recurringSeries: one(recurringSeries, {
fields: [transactions.recurringSeriesId],
references: [recurringSeries.id],
}),
})); }));
export const recurringSeriesRelations = relations(
recurringSeries,
({ one, many }) => ({
user: one(user, {
fields: [recurringSeries.userId],
references: [user.id],
}),
transactions: many(transactions),
}),
);
export const installmentAnticipationsRelations = relations( export const installmentAnticipationsRelations = relations(
installmentAnticipations, installmentAnticipations,
({ one, many }) => ({ ({ one, many }) => ({
@@ -938,5 +869,3 @@ export type ApiToken = typeof apiTokens.$inferSelect;
export type NewApiToken = typeof apiTokens.$inferInsert; export type NewApiToken = typeof apiTokens.$inferInsert;
export type InboxItem = typeof inboxItems.$inferSelect; export type InboxItem = typeof inboxItems.$inferSelect;
export type NewInboxItem = typeof inboxItems.$inferInsert; export type NewInboxItem = typeof inboxItems.$inferInsert;
export type RecurringSeries = typeof recurringSeries.$inferSelect;
export type NewRecurringSeries = typeof recurringSeries.$inferInsert;

View File

@@ -1,153 +0,0 @@
"use client";
import {
RiPauseCircleLine,
RiPlayCircleLine,
RiRefreshLine,
RiStopCircleLine,
} from "@remixicon/react";
import { useTransition } from "react";
import { toast } from "sonner";
import type { RecurringSeriesData } from "@/features/dashboard/recurring/recurring-series-queries";
import {
cancelRecurringSeriesAction,
pauseRecurringSeriesAction,
resumeRecurringSeriesAction,
} from "@/features/recurring/actions";
import MoneyValues from "@/shared/components/money-values";
import { Badge } from "@/shared/components/ui/badge";
import { Button } from "@/shared/components/ui/button";
import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
import { formatMonthYearLabel } from "@/shared/utils/period";
type RecurringSeriesWidgetProps = {
data: RecurringSeriesData;
};
export function RecurringSeriesWidget({ data }: RecurringSeriesWidgetProps) {
const [isPending, startTransition] = useTransition();
if (data.series.length === 0) {
return (
<WidgetEmptyState
icon={<RiRefreshLine className="size-6 text-muted-foreground" />}
title="Nenhuma série recorrente"
description="Séries recorrentes aparecerão aqui quando forem criadas."
/>
);
}
const handlePause = (seriesId: string) => {
startTransition(async () => {
const result = await pauseRecurringSeriesAction({ seriesId });
if (result.success) {
toast.success(result.message);
} else {
toast.error(result.error);
}
});
};
const handleResume = (seriesId: string) => {
startTransition(async () => {
const result = await resumeRecurringSeriesAction({ seriesId });
if (result.success) {
toast.success(result.message);
} else {
toast.error(result.error);
}
});
};
const handleCancel = (seriesId: string) => {
if (
!confirm(
"Tem certeza que deseja cancelar esta série recorrente? Lançamentos passados serão mantidos.",
)
) {
return;
}
startTransition(async () => {
const result = await cancelRecurringSeriesAction({ seriesId });
if (result.success) {
toast.success(result.message);
} else {
toast.error(result.error);
}
});
};
return (
<div className="flex flex-col gap-4 px-0">
<ul className="flex flex-col gap-2">
{data.series.map((item) => (
<li
key={item.id}
className="flex items-center gap-3 border-b border-dashed pb-2 last:border-b-0 last:pb-0"
>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0 flex-1">
<p className="truncate text-foreground text-sm font-medium">
{item.name}
</p>
<Badge
variant={item.status === "active" ? "default" : "secondary"}
className="shrink-0 text-[10px] px-1.5 py-0"
>
{item.status === "active" ? "Ativo" : "Pausado"}
</Badge>
</div>
<MoneyValues amount={item.amount} />
</div>
<div className="flex items-center justify-between text-xs text-muted-foreground mt-0.5">
<span>
Dia {item.dayOfMonth} · {item.paymentMethod}
{item.categoryName ? ` · ${item.categoryName}` : ""}
</span>
<span>Próx: {formatMonthYearLabel(item.nextPeriod)}</span>
</div>
<div className="flex items-center gap-1 mt-1.5">
{item.status === "active" ? (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
disabled={isPending}
onClick={() => handlePause(item.id)}
>
<RiPauseCircleLine className="size-3.5 mr-1" />
Pausar
</Button>
) : (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
disabled={isPending}
onClick={() => handleResume(item.id)}
>
<RiPlayCircleLine className="size-3.5 mr-1" />
Continuar
</Button>
)}
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs text-destructive hover:text-destructive"
disabled={isPending}
onClick={() => handleCancel(item.id)}
>
<RiStopCircleLine className="size-3.5 mr-1" />
Cancelar
</Button>
</div>
</div>
</li>
))}
</ul>
</div>
);
}

View File

@@ -16,7 +16,6 @@ import { fetchPaymentConditions } from "./payments/payment-conditions-queries";
import { fetchPaymentMethods } from "./payments/payment-methods-queries"; import { fetchPaymentMethods } from "./payments/payment-methods-queries";
import { fetchPaymentStatus } from "./payments/payment-status-queries"; import { fetchPaymentStatus } from "./payments/payment-status-queries";
import { fetchPurchasesByCategory } from "./purchases-by-category-queries"; import { fetchPurchasesByCategory } from "./purchases-by-category-queries";
import { fetchRecurringSeries } from "./recurring/recurring-series-queries";
import { fetchTopEstablishments } from "./top-establishments-queries"; import { fetchTopEstablishments } from "./top-establishments-queries";
async function fetchDashboardDataInternal(userId: string, period: string) { async function fetchDashboardDataInternal(userId: string, period: string) {
@@ -40,7 +39,6 @@ async function fetchDashboardDataInternal(userId: string, period: string) {
purchasesByCategoryData, purchasesByCategoryData,
incomeByCategoryData, incomeByCategoryData,
expensesByCategoryData, expensesByCategoryData,
recurringSeriesData,
] = await Promise.all([ ] = await Promise.all([
fetchDashboardCardMetrics(userId, period), fetchDashboardCardMetrics(userId, period),
fetchDashboardAccounts(userId), fetchDashboardAccounts(userId),
@@ -61,7 +59,6 @@ async function fetchDashboardDataInternal(userId: string, period: string) {
fetchPurchasesByCategory(userId, period), fetchPurchasesByCategory(userId, period),
fetchIncomeByCategory(userId, period), fetchIncomeByCategory(userId, period),
fetchExpensesByCategory(userId, period), fetchExpensesByCategory(userId, period),
fetchRecurringSeries(userId),
]); ]);
return { return {
@@ -84,7 +81,6 @@ async function fetchDashboardDataInternal(userId: string, period: string) {
purchasesByCategoryData, purchasesByCategoryData,
incomeByCategoryData, incomeByCategoryData,
expensesByCategoryData, expensesByCategoryData,
recurringSeriesData,
}; };
} }

View File

@@ -1,98 +0,0 @@
import { and, eq, inArray } from "drizzle-orm";
import type { RecurringSeriesTemplate } from "@/db/schema";
import { categories, recurringSeries } from "@/db/schema";
import { db } from "@/shared/lib/db";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
import { safeToNumber as toNumber } from "@/shared/utils/number";
import { addMonthsToPeriod } from "@/shared/utils/period";
export type RecurringSeriesItem = {
id: string;
name: string;
amount: number;
categoryName: string | null;
categoryIcon: string | null;
paymentMethod: string;
dayOfMonth: number;
status: "active" | "paused" | "cancelled";
nextPeriod: string;
lastGeneratedPeriod: string;
};
export type RecurringSeriesData = {
series: RecurringSeriesItem[];
};
export async function fetchRecurringSeries(
userId: string,
): Promise<RecurringSeriesData> {
const adminPayerId = await getAdminPayerId(userId);
const rows = await db
.select({
id: recurringSeries.id,
status: recurringSeries.status,
dayOfMonth: recurringSeries.dayOfMonth,
lastGeneratedPeriod: recurringSeries.lastGeneratedPeriod,
templateData: recurringSeries.templateData,
})
.from(recurringSeries)
.where(
and(
eq(recurringSeries.userId, userId),
inArray(recurringSeries.status, ["active", "paused"]),
),
);
if (rows.length === 0) {
return { series: [] };
}
// Fetch category names for all series in one query
const categoryIds = rows
.map((r) => (r.templateData as RecurringSeriesTemplate).categoryId)
.filter((id): id is string => id !== null);
const categoryMap = new Map<string, { name: string; icon: string | null }>();
if (categoryIds.length > 0) {
const cats = await db
.select({
id: categories.id,
name: categories.name,
icon: categories.icon,
})
.from(categories)
.where(inArray(categories.id, categoryIds));
for (const cat of cats) {
categoryMap.set(cat.id, { name: cat.name, icon: cat.icon });
}
}
const series = rows
.filter((row) => {
// If admin pagador exists, only show series belonging to admin
if (!adminPayerId) return true;
const template = row.templateData as RecurringSeriesTemplate;
return template.payerId === adminPayerId || template.payerId === null;
})
.map((row): RecurringSeriesItem => {
const template = row.templateData as RecurringSeriesTemplate;
const category = template.categoryId
? categoryMap.get(template.categoryId)
: null;
return {
id: row.id,
name: template.name,
amount: Math.abs(toNumber(template.amount)),
categoryName: category?.name ?? null,
categoryIcon: category?.icon ?? null,
paymentMethod: template.paymentMethod,
dayOfMonth: row.dayOfMonth,
status: row.status as "active" | "paused",
nextPeriod: addMonthsToPeriod(row.lastGeneratedPeriod, 1),
lastGeneratedPeriod: row.lastGeneratedPeriod,
};
});
return { series };
}

View File

@@ -30,7 +30,6 @@ import { PaymentOverviewWidget } from "@/features/dashboard/components/payment-o
import { PaymentStatusWidget } from "@/features/dashboard/components/payment-status-widget"; import { PaymentStatusWidget } from "@/features/dashboard/components/payment-status-widget";
import { PurchasesByCategoryWidget } from "@/features/dashboard/components/purchases-by-category-widget"; import { PurchasesByCategoryWidget } from "@/features/dashboard/components/purchases-by-category-widget";
import { RecurringExpensesWidget } from "@/features/dashboard/components/recurring-expenses-widget"; import { RecurringExpensesWidget } from "@/features/dashboard/components/recurring-expenses-widget";
import { RecurringSeriesWidget } from "@/features/dashboard/components/recurring-series-widget";
import { SpendingOverviewWidget } from "@/features/dashboard/components/spending-overview-widget"; import { SpendingOverviewWidget } from "@/features/dashboard/components/spending-overview-widget";
import type { DashboardData } from "../fetch-dashboard-data"; import type { DashboardData } from "../fetch-dashboard-data";
@@ -164,15 +163,6 @@ export const widgetsConfig: WidgetConfig[] = [
<RecurringExpensesWidget data={data.recurringExpensesData} /> <RecurringExpensesWidget data={data.recurringExpensesData} />
), ),
}, },
{
id: "recurring-series",
title: "Séries Recorrentes",
subtitle: "Gerencie seus lançamentos recorrentes",
icon: <RiRefreshLine className="size-4" />,
component: ({ data }) => (
<RecurringSeriesWidget data={data.recurringSeriesData} />
),
},
{ {
id: "installment-expenses", id: "installment-expenses",
title: "Lançamentos Parcelados", title: "Lançamentos Parcelados",

View File

@@ -1,144 +0,0 @@
"use server";
import { and, eq } from "drizzle-orm";
import { recurringSeries } from "@/db/schema";
import { generateRecurringTransactions } from "@/features/recurring/generate-recurring";
import {
handleActionError,
revalidateForEntity,
} from "@/shared/lib/actions/helpers";
import { getUser } from "@/shared/lib/auth/server";
import { db } from "@/shared/lib/db";
import { recurringSeriesActionSchema } from "@/shared/lib/schemas/recurring-series";
import type { ActionResult } from "@/shared/lib/types/actions";
const revalidate = () => revalidateForEntity("recurring");
async function findRecurringSeriesForUser(userId: string, seriesId: string) {
const [series] = await db
.select({
id: recurringSeries.id,
status: recurringSeries.status,
})
.from(recurringSeries)
.where(
and(eq(recurringSeries.id, seriesId), eq(recurringSeries.userId, userId)),
)
.limit(1);
return series ?? null;
}
export async function pauseRecurringSeriesAction(input: {
seriesId: string;
}): Promise<ActionResult> {
try {
const user = await getUser();
const data = recurringSeriesActionSchema.parse(input);
const existing = await findRecurringSeriesForUser(user.id, data.seriesId);
if (!existing) {
return { success: false, error: "Série recorrente não encontrada." };
}
if (existing.status !== "active") {
return {
success: false,
error: "Apenas séries ativas podem ser pausadas.",
};
}
await db
.update(recurringSeries)
.set({ status: "paused", updatedAt: new Date() })
.where(
and(
eq(recurringSeries.id, data.seriesId),
eq(recurringSeries.userId, user.id),
),
);
revalidate();
return { success: true, message: "Série recorrente pausada." };
} catch (error) {
return handleActionError(error);
}
}
export async function resumeRecurringSeriesAction(input: {
seriesId: string;
}): Promise<ActionResult> {
try {
const user = await getUser();
const data = recurringSeriesActionSchema.parse(input);
const existing = await findRecurringSeriesForUser(user.id, data.seriesId);
if (!existing) {
return { success: false, error: "Série recorrente não encontrada." };
}
if (existing.status !== "paused") {
return {
success: false,
error: "Apenas séries pausadas podem ser retomadas.",
};
}
await db
.update(recurringSeries)
.set({ status: "active", updatedAt: new Date() })
.where(
and(
eq(recurringSeries.id, data.seriesId),
eq(recurringSeries.userId, user.id),
),
);
// Trigger catch-up generation for missed months
await generateRecurringTransactions(user.id);
revalidate();
return { success: true, message: "Série recorrente retomada." };
} catch (error) {
return handleActionError(error);
}
}
export async function cancelRecurringSeriesAction(input: {
seriesId: string;
}): Promise<ActionResult> {
try {
const user = await getUser();
const data = recurringSeriesActionSchema.parse(input);
const existing = await findRecurringSeriesForUser(user.id, data.seriesId);
if (!existing) {
return { success: false, error: "Série recorrente não encontrada." };
}
if (existing.status === "cancelled") {
return {
success: false,
error: "Esta série já está cancelada.",
};
}
await db
.update(recurringSeries)
.set({ status: "cancelled", updatedAt: new Date() })
.where(
and(
eq(recurringSeries.id, data.seriesId),
eq(recurringSeries.userId, user.id),
),
);
revalidate();
return { success: true, message: "Série recorrente cancelada." };
} catch (error) {
return handleActionError(error);
}
}

View File

@@ -1,126 +0,0 @@
import { and, eq } from "drizzle-orm";
import { recurringSeries, transactions } from "@/db/schema";
import { db } from "@/shared/lib/db";
import {
addMonthsToPeriod,
comparePeriods,
getCurrentPeriod,
getNextPeriod,
parsePeriod,
} from "@/shared/utils/period";
/**
* Computes the purchase date for a given period and day of month.
* Clamps to last day of month for short months (e.g., Feb 30 → Feb 28).
*/
function computePurchaseDate(period: string, dayOfMonth: number): Date {
const { year, month } = parsePeriod(period);
// month is 1-indexed, Date constructor expects 0-indexed
const lastDayOfMonth = new Date(year, month, 0).getDate();
const clampedDay = Math.min(dayOfMonth, lastDayOfMonth);
return new Date(year, month - 1, clampedDay);
}
/**
* Generates missing recurring transactions for a single user.
*
* For each active recurring series:
* 1. Determines which months are missing between lastGeneratedPeriod and current month
* 2. Creates lancamento rows for each missing month using the template data
* 3. Updates lastGeneratedPeriod on the series
*
* Uses a DB transaction for atomicity.
*/
export async function generateRecurringTransactions(
userId: string,
): Promise<{ generated: number }> {
const currentPeriod = getCurrentPeriod();
// Fetch all active recurring series for this user
const activeSeries = await db
.select()
.from(recurringSeries)
.where(
and(
eq(recurringSeries.userId, userId),
eq(recurringSeries.status, "active"),
),
);
if (activeSeries.length === 0) {
return { generated: 0 };
}
let totalGenerated = 0;
for (const series of activeSeries) {
// Determine missing periods: from lastGeneratedPeriod + 1 to currentPeriod
const startPeriod = getNextPeriod(series.lastGeneratedPeriod);
// If startPeriod is already past the current period, nothing to generate
if (comparePeriods(startPeriod, currentPeriod) > 0) {
continue;
}
// Build list of periods to generate
const periodsToGenerate: string[] = [];
let iterPeriod = startPeriod;
while (comparePeriods(iterPeriod, currentPeriod) <= 0) {
periodsToGenerate.push(iterPeriod);
iterPeriod = addMonthsToPeriod(iterPeriod, 1);
}
if (periodsToGenerate.length === 0) {
continue;
}
const template = series.templateData;
// Create all transactions for missing periods in a transaction
await db.transaction(async (tx: typeof db) => {
const records = periodsToGenerate.map((period) => {
const purchaseDate = computePurchaseDate(period, series.dayOfMonth);
return {
name: template.name,
amount: template.amount,
transactionType: template.transactionType,
paymentMethod: template.paymentMethod,
condition: "Recorrente" as const,
categoryId: template.categoryId,
accountId: template.accountId,
cardId: template.cardId,
payerId: template.payerId,
note: template.note,
purchaseDate,
period,
isSettled: false,
recurrenceCount: null,
installmentCount: null,
currentInstallment: null,
isDivided: false,
userId,
seriesId: series.id,
recurringSeriesId: series.id,
};
});
await tx.insert(transactions).values(records);
// Update lastGeneratedPeriod to the last period we generated
const lastPeriod =
periodsToGenerate[periodsToGenerate.length - 1] ??
series.lastGeneratedPeriod;
await tx
.update(recurringSeries)
.set({
lastGeneratedPeriod: lastPeriod,
updatedAt: new Date(),
})
.where(eq(recurringSeries.id, series.id));
});
totalGenerated += periodsToGenerate.length;
}
return { generated: totalGenerated };
}

View File

@@ -1,23 +0,0 @@
import { cache } from "react";
import { generateRecurringTransactions } from "./generate-recurring";
/**
* Triggers recurring transaction generation for a user.
* Deduped per-request via React.cache to avoid multiple calls
* during the same server render (layout + page).
*
* Call this at the top of dashboard and lancamentos page server components.
*/
export const triggerRecurringGeneration = cache(
async (userId: string): Promise<void> => {
try {
await generateRecurringTransactions(userId);
} catch (error) {
// Log but don't throw — generation failure should not block page render
console.error(
"[RecurringGeneration] Failed to generate recurring transactions:",
error,
);
}
},
);

View File

@@ -1,4 +0,0 @@
// Re-export from schema for convenience
export type { RecurringSeriesTemplate } from "@/db/schema";
export type RecurringSeriesStatus = "active" | "paused" | "cancelled";

View File

@@ -3,13 +3,11 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { and, asc, eq, inArray, sql } from "drizzle-orm"; import { and, asc, eq, inArray, sql } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
import type { RecurringSeriesTemplate } from "@/db/schema";
import { import {
cards, cards,
categories, categories,
financialAccounts, financialAccounts,
payers, payers,
recurringSeries,
transactions, transactions,
} from "@/db/schema"; } from "@/db/schema";
import { 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.condition === "Parcelado") {
if (!data.installmentCount) { if (!data.installmentCount) {
ctx.addIssue({ ctx.addIssue({
@@ -518,23 +532,33 @@ const buildLancamentoRecords = ({
} }
if (data.condition === "Recorrente") { if (data.condition === "Recorrente") {
// For the new recurring model, only create 1 row (the current month) const recurrenceTotal = data.recurrenceCount ?? 0;
// Future rows will be generated lazily by generateRecurringTransactions
shares.forEach((share) => { for (let index = 0; index < recurrenceTotal; index += 1) {
const settled = resolveSettledValue(0); const recurrencePeriod = addMonthsToPeriod(period, index);
records.push({ const recurrencePurchaseDate = addMonthsToDate(purchaseDate, index);
...basePayload, const recurrenceDueDate = dueDate
amount: centsToDecimalString(share.amountCents * amountSign), ? addMonthsToDate(dueDate, index)
payerId: share.payerId, : null;
purchaseDate,
period, shares.forEach((share) => {
isSettled: settled, const settled = resolveSettledValue(index);
recurrenceCount: null, records.push({
dueDate, ...basePayload,
boletoPaymentDate: amount: centsToDecimalString(share.amountCents * amountSign),
data.paymentMethod === "Boleto" && settled ? boletoPaymentDate : null, payerId: share.payerId,
purchaseDate: recurrencePurchaseDate,
period: recurrencePeriod,
isSettled: settled,
recurrenceCount: recurrenceTotal,
dueDate: recurrenceDueDate,
boletoPaymentDate:
data.paymentMethod === "Boleto" && settled
? boletoPaymentDate
: null,
});
}); });
}); }
return records; return records;
} }
@@ -661,42 +685,7 @@ export async function createTransactionAction(
throw new Error("Não foi possível criar os lançamentos solicitados."); throw new Error("Não foi possível criar os lançamentos solicitados.");
} }
await db.transaction(async (tx: typeof db) => { await db.insert(transactions).values(records);
// 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);
});
const notificationEntries = buildEntriesByPayer( const notificationEntries = buildEntriesByPayer(
records.map((record) => ({ records.map((record) => ({

View File

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

View File

@@ -1,5 +1,5 @@
"use client"; "use client";
import { RiAddLine } from "@remixicon/react"; import { RiArrowDropDownLine } from "@remixicon/react";
import { useEffect, useMemo, useState, useTransition } from "react"; import { useEffect, useMemo, useState, useTransition } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
@@ -292,7 +292,10 @@ export function TransactionDialog({
formState.condition === "Parcelado" && formState.installmentCount formState.condition === "Parcelado" && formState.installmentCount
? Number(formState.installmentCount) ? Number(formState.installmentCount)
: undefined, : undefined,
recurrenceCount: undefined, recurrenceCount:
formState.condition === "Recorrente" && formState.recurrenceCount
? Number(formState.recurrenceCount)
: undefined,
dueDate: dueDate:
formState.paymentMethod === "Boleto" && formState.dueDate formState.paymentMethod === "Boleto" && formState.dueDate
? formState.dueDate ? formState.dueDate
@@ -375,7 +378,7 @@ export function TransactionDialog({
const title = const title =
mode === "create" mode === "create"
? isImportMode ? isImportMode
? "Importar para Minha FinancialAccount" ? "Importar para Minha Conta"
: isCopyMode : isCopyMode
? "Copiar lançamento" ? "Copiar lançamento"
: isNewWithType : 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"> <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 Condições e anotações
</CollapsibleTrigger> </CollapsibleTrigger>
<CollapsibleContent className="space-y-3 pt-3"> <CollapsibleContent className="space-y-3 pt-3">

View File

@@ -33,7 +33,6 @@ export const revalidateConfig = {
notes: ["/notes", "/notes/archived", "/dashboard"], notes: ["/notes", "/notes/archived", "/dashboard"],
transactions: ["/transactions", "/accounts"], transactions: ["/transactions", "/accounts"],
inbox: ["/inbox", "/transactions", "/dashboard"], inbox: ["/inbox", "/transactions", "/dashboard"],
recurring: ["/transactions", "/dashboard"],
} as const; } as const;
/** Entities whose mutations should invalidate the dashboard cache */ /** Entities whose mutations should invalidate the dashboard cache */

View File

@@ -1,13 +0,0 @@
import { z } from "zod";
import { uuidSchema } from "./common";
/**
* Schema for pause/resume/cancel recurring series actions
*/
export const recurringSeriesActionSchema = z.object({
seriesId: uuidSchema("Série recorrente"),
});
export type RecurringSeriesActionInput = z.infer<
typeof recurringSeriesActionSchema
>;