mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-09 02:51:46 +00:00
Remove infraestrutura de series recorrentes
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import type { Config } from "drizzle-kit";
|
||||
|
||||
export default {
|
||||
schema: "./db/schema.ts",
|
||||
schema: "./src/db/schema.ts",
|
||||
out: "./drizzle",
|
||||
dialect: "postgresql",
|
||||
dbCredentials: {
|
||||
|
||||
@@ -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
@@ -134,13 +134,6 @@
|
||||
"when": 1773020417482,
|
||||
"tag": "0018_rainy_epoch",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 19,
|
||||
"version": "7",
|
||||
"when": 1773265586360,
|
||||
"tag": "0019_parched_mephistopheles",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { DashboardMetricsCards } from "@/features/dashboard/components/dashboard
|
||||
import { DashboardWelcome } from "@/features/dashboard/components/dashboard-welcome";
|
||||
import { fetchDashboardData } from "@/features/dashboard/fetch-dashboard-data";
|
||||
import { fetchUserDashboardPreferences } from "@/features/dashboard/preferences-queries";
|
||||
import { triggerRecurringGeneration } from "@/features/recurring/trigger-recurring-generation";
|
||||
import {
|
||||
buildOptionSets,
|
||||
buildSluggedFilters,
|
||||
@@ -25,7 +24,6 @@ type PageProps = {
|
||||
|
||||
export default async function Page({ searchParams }: PageProps) {
|
||||
const user = await getUser();
|
||||
await triggerRecurringGeneration(user.id);
|
||||
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
||||
const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
|
||||
const { period: selectedPeriod } = parsePeriodParam(periodoParam);
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { triggerRecurringGeneration } from "@/features/recurring/trigger-recurring-generation";
|
||||
import { fetchUserPreferences } from "@/features/settings/queries";
|
||||
import { TransactionsPage } from "@/features/transactions/components/page/transactions-page";
|
||||
import {
|
||||
buildTransactionWhere,
|
||||
buildOptionSets,
|
||||
buildSluggedFilters,
|
||||
buildSlugMaps,
|
||||
buildTransactionWhere,
|
||||
extractTransactionSearchFilters,
|
||||
getSingleParam,
|
||||
mapTransactionsData,
|
||||
@@ -28,7 +27,6 @@ type PageProps = {
|
||||
|
||||
export default async function Page({ searchParams }: PageProps) {
|
||||
const userId = await getUserId();
|
||||
await triggerRecurringGeneration(userId);
|
||||
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
||||
|
||||
const periodoParamRaw = getSingleParam(resolvedSearchParams, "periodo");
|
||||
|
||||
@@ -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 =====================
|
||||
|
||||
export const transactions = pgTable(
|
||||
@@ -664,10 +615,6 @@ export const transactions = pgTable(
|
||||
}),
|
||||
seriesId: uuid("series_id"),
|
||||
transferId: uuid("transfer_id"),
|
||||
recurringSeriesId: uuid("recurring_series_id").references(
|
||||
() => recurringSeries.id,
|
||||
{ onDelete: "set null" },
|
||||
),
|
||||
},
|
||||
(table) => ({
|
||||
// Í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),
|
||||
apiTokens: many(apiTokens),
|
||||
inboxItems: many(inboxItems),
|
||||
recurringSeries: many(recurringSeries),
|
||||
}));
|
||||
|
||||
export const accountRelations = relations(account, ({ one }) => ({
|
||||
@@ -876,23 +822,8 @@ export const transactionsRelations = relations(transactions, ({ one }) => ({
|
||||
fields: [transactions.anticipationId],
|
||||
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(
|
||||
installmentAnticipations,
|
||||
({ one, many }) => ({
|
||||
@@ -938,5 +869,3 @@ export type ApiToken = typeof apiTokens.$inferSelect;
|
||||
export type NewApiToken = typeof apiTokens.$inferInsert;
|
||||
export type InboxItem = typeof inboxItems.$inferSelect;
|
||||
export type NewInboxItem = typeof inboxItems.$inferInsert;
|
||||
export type RecurringSeries = typeof recurringSeries.$inferSelect;
|
||||
export type NewRecurringSeries = typeof recurringSeries.$inferInsert;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -16,7 +16,6 @@ import { fetchPaymentConditions } from "./payments/payment-conditions-queries";
|
||||
import { fetchPaymentMethods } from "./payments/payment-methods-queries";
|
||||
import { fetchPaymentStatus } from "./payments/payment-status-queries";
|
||||
import { fetchPurchasesByCategory } from "./purchases-by-category-queries";
|
||||
import { fetchRecurringSeries } from "./recurring/recurring-series-queries";
|
||||
import { fetchTopEstablishments } from "./top-establishments-queries";
|
||||
|
||||
async function fetchDashboardDataInternal(userId: string, period: string) {
|
||||
@@ -40,7 +39,6 @@ async function fetchDashboardDataInternal(userId: string, period: string) {
|
||||
purchasesByCategoryData,
|
||||
incomeByCategoryData,
|
||||
expensesByCategoryData,
|
||||
recurringSeriesData,
|
||||
] = await Promise.all([
|
||||
fetchDashboardCardMetrics(userId, period),
|
||||
fetchDashboardAccounts(userId),
|
||||
@@ -61,7 +59,6 @@ async function fetchDashboardDataInternal(userId: string, period: string) {
|
||||
fetchPurchasesByCategory(userId, period),
|
||||
fetchIncomeByCategory(userId, period),
|
||||
fetchExpensesByCategory(userId, period),
|
||||
fetchRecurringSeries(userId),
|
||||
]);
|
||||
|
||||
return {
|
||||
@@ -84,7 +81,6 @@ async function fetchDashboardDataInternal(userId: string, period: string) {
|
||||
purchasesByCategoryData,
|
||||
incomeByCategoryData,
|
||||
expensesByCategoryData,
|
||||
recurringSeriesData,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -30,7 +30,6 @@ import { PaymentOverviewWidget } from "@/features/dashboard/components/payment-o
|
||||
import { PaymentStatusWidget } from "@/features/dashboard/components/payment-status-widget";
|
||||
import { PurchasesByCategoryWidget } from "@/features/dashboard/components/purchases-by-category-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 type { DashboardData } from "../fetch-dashboard-data";
|
||||
|
||||
@@ -164,15 +163,6 @@ export const widgetsConfig: WidgetConfig[] = [
|
||||
<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",
|
||||
title: "Lançamentos Parcelados",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -1,4 +0,0 @@
|
||||
// Re-export from schema for convenience
|
||||
export type { RecurringSeriesTemplate } from "@/db/schema";
|
||||
|
||||
export type RecurringSeriesStatus = "active" | "paused" | "cancelled";
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -33,7 +33,6 @@ export const revalidateConfig = {
|
||||
notes: ["/notes", "/notes/archived", "/dashboard"],
|
||||
transactions: ["/transactions", "/accounts"],
|
||||
inbox: ["/inbox", "/transactions", "/dashboard"],
|
||||
recurring: ["/transactions", "/dashboard"],
|
||||
} as const;
|
||||
|
||||
/** Entities whose mutations should invalidate the dashboard cache */
|
||||
|
||||
@@ -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
|
||||
>;
|
||||
Reference in New Issue
Block a user