chore: preparar versão 2.7.13

This commit is contained in:
Felipe Coutinho
2026-08-09 19:32:12 -03:00
parent ed9196797b
commit bf4138db82
31 changed files with 4299 additions and 744 deletions

View File

@@ -1,6 +1,7 @@
import { connection } from "next/server";
import { AttachmentsPage } from "@/features/attachments/components/attachments-page";
import { fetchAttachmentsPageData } from "@/features/attachments/queries";
import { ContentErrorBoundary } from "@/shared/components/feedback/content-error-boundary";
import { getUserId } from "@/shared/lib/auth/server";
import { parsePeriodParam } from "@/shared/utils/period";
@@ -19,7 +20,18 @@ const getSingleParam = (
return Array.isArray(value) ? (value[0] ?? null) : value;
};
export default async function Page({ searchParams }: PageProps) {
export default function Page({ searchParams }: PageProps) {
return (
<ContentErrorBoundary
title="Não foi possível carregar os anexos"
description="Os documentos e comprovantes não puderam ser carregados agora."
>
<AttachmentsContent searchParams={searchParams} />
</ContentErrorBoundary>
);
}
async function AttachmentsContent({ searchParams }: PageProps) {
await connection();
const userId = await getUserId();
const resolvedSearchParams = searchParams ? await searchParams : undefined;

View File

@@ -6,6 +6,7 @@ import { extractDashboardLogoNames } from "@/features/dashboard/lib/extract-logo
import { fetchDashboardPageData } from "@/features/dashboard/page-data-queries";
import { getSingleParam } from "@/features/transactions/lib/page-helpers";
import { LogoPrefetchProvider } from "@/shared/components/entity-avatar";
import { ContentErrorBoundary } from "@/shared/components/feedback/content-error-boundary";
import MonthNavigation from "@/shared/components/month-picker/month-navigation";
import { getUser } from "@/shared/lib/auth/server";
import { prefetchLogoMappings } from "@/shared/lib/logo/prefetch-server";
@@ -17,7 +18,18 @@ type PageProps = {
searchParams?: PageSearchParams;
};
export default async function Page({ searchParams }: PageProps) {
export default function Page({ searchParams }: PageProps) {
return (
<ContentErrorBoundary
title="Não foi possível carregar o dashboard"
description="Seus dados financeiros não puderam ser carregados agora."
>
<DashboardContent searchParams={searchParams} />
</ContentErrorBoundary>
);
}
async function DashboardContent({ searchParams }: PageProps) {
await connection();
const user = await getUser();
const resolvedSearchParams = searchParams ? await searchParams : undefined;
@@ -41,19 +53,29 @@ export default async function Page({ searchParams }: PageProps) {
<main className="flex flex-col gap-4">
<DashboardWelcome name={user.name} />
<MonthNavigation />
<DashboardMetricsCards
metrics={dashboardData.metrics}
period={selectedPeriod}
adminPayerSlug={adminPayerSlug}
/>
<LogoPrefetchProvider mappings={logoMappings}>
<DashboardGridEditable
data={dashboardData}
<ContentErrorBoundary
title="Não foi possível exibir o resumo"
description="Os indicadores do período não puderam ser exibidos agora."
>
<DashboardMetricsCards
metrics={dashboardData.metrics}
period={selectedPeriod}
initialPreferences={dashboardWidgets}
quickActionOptions={quickActionOptions}
adminPayerSlug={adminPayerSlug}
/>
</LogoPrefetchProvider>
</ContentErrorBoundary>
<ContentErrorBoundary
title="Não foi possível exibir os widgets"
description="Os detalhes do dashboard não puderam ser exibidos agora."
>
<LogoPrefetchProvider mappings={logoMappings}>
<DashboardGridEditable
data={dashboardData}
period={selectedPeriod}
initialPreferences={dashboardWidgets}
quickActionOptions={quickActionOptions}
/>
</LogoPrefetchProvider>
</ContentErrorBoundary>
</main>
);
}

View File

@@ -1,5 +1,6 @@
import { connection } from "next/server";
import { InsightsPage } from "@/features/insights/components/insights-page";
import { ContentErrorBoundary } from "@/shared/components/feedback/content-error-boundary";
import MonthNavigation from "@/shared/components/month-picker/month-navigation";
import { parsePeriodParam } from "@/shared/utils/period";
@@ -18,7 +19,18 @@ const getSingleParam = (
return Array.isArray(value) ? (value[0] ?? null) : value;
};
export default async function Page({ searchParams }: PageProps) {
export default function Page({ searchParams }: PageProps) {
return (
<ContentErrorBoundary
title="Não foi possível carregar os Insights"
description="As análises financeiras não puderam ser carregadas agora."
>
<InsightsContent searchParams={searchParams} />
</ContentErrorBoundary>
);
}
async function InsightsContent({ searchParams }: PageProps) {
await connection();
const resolvedSearchParams = searchParams ? await searchParams : undefined;
const periodoParam = getSingleParam(resolvedSearchParams, "periodo");

View File

@@ -6,6 +6,7 @@ import { CardTopExpenses } from "@/features/reports/components/cards/card-top-ex
import { CardUsageChart } from "@/features/reports/components/cards/card-usage-chart";
import { CardsOverview } from "@/features/reports/components/cards/cards-overview";
import { fetchCartoesReportData } from "@/features/reports/lib/cards-report-queries";
import { ContentErrorBoundary } from "@/shared/components/feedback/content-error-boundary";
import MonthNavigation from "@/shared/components/month-picker/month-navigation";
import { Card } from "@/shared/components/ui/card";
import { getUser } from "@/shared/lib/auth/server";
@@ -26,9 +27,18 @@ const getSingleParam = (
return Array.isArray(value) ? (value[0] ?? null) : value;
};
export default async function RelatorioCartoesPage({
searchParams,
}: PageProps) {
export default function CardUsagePage({ searchParams }: PageProps) {
return (
<ContentErrorBoundary
title="Não foi possível carregar o uso dos cartões"
description="Os dados deste relatório não puderam ser carregados agora."
>
<CardUsageContent searchParams={searchParams} />
</ContentErrorBoundary>
);
}
async function CardUsageContent({ searchParams }: PageProps) {
await connection();
const user = await getUser();
const resolvedSearchParams = searchParams ? await searchParams : undefined;

View File

@@ -10,6 +10,7 @@ import { fetchCategoryChartData } from "@/features/reports/lib/category-chart-qu
import { fetchCategoryReport } from "@/features/reports/lib/category-report-queries";
import { fetchUserCategories } from "@/features/reports/lib/category-trends-queries";
import { validateDateRange } from "@/features/reports/lib/utils";
import { ContentErrorBoundary } from "@/shared/components/feedback/content-error-boundary";
import { getUserId } from "@/shared/lib/auth/server";
import type { CategoryReportFilters } from "@/shared/lib/types/reports";
import { addMonthsToPeriod, getCurrentPeriod } from "@/shared/utils/period";
@@ -29,7 +30,18 @@ const getSingleParam = (
return Array.isArray(value) ? (value[0] ?? null) : value;
};
export default async function Page({ searchParams }: PageProps) {
export default function Page({ searchParams }: PageProps) {
return (
<ContentErrorBoundary
title="Não foi possível carregar as tendências"
description="A evolução das categorias não pôde ser carregada agora."
>
<CategoryTrendsContent searchParams={searchParams} />
</ContentErrorBoundary>
);
}
async function CategoryTrendsContent({ searchParams }: PageProps) {
await connection();
// Get authenticated user
const userId = await getUserId();

View File

@@ -8,6 +8,7 @@ import {
fetchTopEstablishmentsData,
type PeriodFilter,
} from "@/features/reports/establishments/queries";
import { ContentErrorBoundary } from "@/shared/components/feedback/content-error-boundary";
import { Card } from "@/shared/components/ui/card";
import { getUser } from "@/shared/lib/auth/server";
import { parsePeriodParam } from "@/shared/utils/period";
@@ -34,9 +35,18 @@ const validatePeriodFilter = (value: string | null): PeriodFilter => {
return "6";
};
export default async function TopEstablishmentsPage({
searchParams,
}: PageProps) {
export default function EstablishmentsPage({ searchParams }: PageProps) {
return (
<ContentErrorBoundary
title="Não foi possível carregar os estabelecimentos"
description="Os dados deste relatório não puderam ser carregados agora."
>
<EstablishmentsContent searchParams={searchParams} />
</ContentErrorBoundary>
);
}
async function EstablishmentsContent({ searchParams }: PageProps) {
await connection();
const user = await getUser();
const resolvedSearchParams = searchParams ? await searchParams : undefined;

View File

@@ -2,10 +2,22 @@ import { connection } from "next/server";
import { InstallmentAnalysisPage } from "@/features/dashboard/components/installment-analysis/installment-analysis-page";
import { fetchInstallmentAnalysis } from "@/features/dashboard/expenses/installment-analysis-queries";
import { LogoPrefetchProvider } from "@/shared/components/entity-avatar";
import { ContentErrorBoundary } from "@/shared/components/feedback/content-error-boundary";
import { getUser } from "@/shared/lib/auth/server";
import { prefetchLogoMappings } from "@/shared/lib/logo/prefetch-server";
export default async function Page() {
export default function Page() {
return (
<ContentErrorBoundary
title="Não foi possível carregar a análise de parcelas"
description="Os parcelamentos não puderam ser analisados agora."
>
<InstallmentAnalysisContent />
</ContentErrorBoundary>
);
}
async function InstallmentAnalysisContent() {
await connection();
const user = await getUser();
const data = await fetchInstallmentAnalysis(user.id);

View File

@@ -4,11 +4,10 @@ import {
RiShieldCheckLine,
RiSmartphoneLine,
} from "@remixicon/react";
import { headers } from "next/headers";
import Image from "next/image";
import Link from "next/link";
import { AnimateOnScroll } from "@/features/landing/components/animate-on-scroll";
import { MobileNav } from "@/features/landing/components/mobile-nav";
import { LandingNavbar } from "@/features/landing/components/landing-navbar";
import { SetupTabs } from "@/features/landing/components/setup-tabs";
import {
companionBanks,
@@ -16,98 +15,31 @@ import {
extraFeatures,
getMetricsItems,
mainFeatures,
navLinks,
pwaHighlights,
stackItems,
whoIsItForItems,
} from "@/features/landing/constants";
import { landingImages } from "@/features/landing/images";
import { fetchGitHubStats } from "@/features/landing/queries";
import { AnimatedThemeToggler } from "@/shared/components/animated-theme-toggler";
import {
fetchGitHubStats,
getLandingCopyrightYear,
} from "@/features/landing/queries";
import { Logo } from "@/shared/components/brand/logo";
import { NavbarShell } from "@/shared/components/navigation/navbar/navbar-shell";
import { Badge } from "@/shared/components/ui/badge";
import { Button } from "@/shared/components/ui/button";
import { Card, CardContent } from "@/shared/components/ui/card";
import { getOptionalUserSession } from "@/shared/lib/auth/server";
import { isSignupDisabled } from "@/shared/lib/auth/signup";
export default async function Page() {
const [session, headersList, githubStats] = await Promise.all([
getOptionalUserSession(),
headers(),
const [githubStats, copyrightYear] = await Promise.all([
fetchGitHubStats(),
getLandingCopyrightYear(),
]);
const hostname = headersList.get("host")?.replace(/:\d+$/, "");
const publicDomain = process.env.PUBLIC_DOMAIN?.replace(
/^https?:\/\//,
"",
).replace(/:\d+$/, "");
const isPublicDomain = !!(publicDomain && hostname === publicDomain);
const signupDisabled = isSignupDisabled();
const metricsItems = getMetricsItems(githubStats.stars, githubStats.forks);
return (
<div className="flex min-h-screen flex-col">
{/* Navigation */}
<NavbarShell>
{/* Center Navigation Links */}
<nav className="hidden md:flex items-center gap-1 absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
{navLinks.map(({ href, label }) => (
<Link
key={href}
href={href}
className="inline-flex h-9 items-center justify-center rounded-md px-2 text-sm font-medium leading-none text-primary-foreground/75 transition-colors hover:bg-primary-foreground/10 hover:text-primary-foreground dark:text-white/75 dark:hover:bg-white/10 dark:hover:text-white"
>
{label}
</Link>
))}
</nav>
<nav className="ml-auto flex items-center gap-1">
<AnimatedThemeToggler variant="navbar" />
{!isPublicDomain &&
(session?.user ? (
<Link prefetch href="/dashboard" className="hidden md:block">
<Button
variant="navbar"
size="sm"
className="h-9 text-primary-foreground/75 hover:bg-primary-foreground/10 hover:text-primary-foreground shadow-none dark:text-white/75 dark:hover:bg-white/10 dark:hover:text-white"
>
Dashboard
</Button>
</Link>
) : (
<div className="hidden md:flex items-center gap-1">
<Link href="/login">
<Button
variant="ghost"
size="sm"
className="h-9 text-primary-foreground/75 hover:bg-primary-foreground/10 hover:text-primary-foreground shadow-none dark:text-white/75 dark:hover:bg-white/10 dark:hover:text-white"
>
Entrar
</Button>
</Link>
{!signupDisabled && (
<Link href="/signup">
<Button
variant="ghost"
size="sm"
className="h-9 text-primary-foreground/75 hover:bg-primary-foreground/10 hover:text-primary-foreground shadow-none dark:text-white/75 dark:hover:bg-white/10 dark:hover:text-white"
>
Começar
</Button>
</Link>
)}
</div>
))}
<MobileNav
isPublicDomain={isPublicDomain}
isLoggedIn={!!session?.user}
signupDisabled={signupDisabled}
/>
</nav>
</NavbarShell>
<LandingNavbar />
{/* Hero Section */}
<section className="relative overflow-hidden pt-14 md:pt-20 lg:pt-24 pb-0">
@@ -698,8 +630,7 @@ export default async function Page() {
<div className="border-t mt-8 md:mt-12 pt-6 md:pt-8 flex flex-col md:flex-row justify-between items-center gap-3 md:gap-4 text-sm text-muted-foreground">
<p>
© {new Date().getFullYear()} openmonetis. Projeto open source
sob licença.
© {copyrightYear} openmonetis. Projeto open source sob licença.
</p>
<div className="flex items-center gap-2">
<RiShieldCheckLine className="size-4 text-primary" />

View File

@@ -682,6 +682,7 @@ export const transactions = pgTable(
splitGroupId: uuid("split_group_id"),
transferId: uuid("transfer_id"),
ofxFitId: text("ofx_fit_id"),
ofxImportFingerprint: text("ofx_import_fingerprint"),
importBatchId: text("import_batch_id"),
},
(table) => ({
@@ -735,10 +736,12 @@ export const transactions = pgTable(
anticipationIdIdx: index("lancamentos_antecipacao_id_idx").on(
table.anticipationId,
),
// Dedup OFX: garante FITID único por usuário
ofxFitIdUserIdIdx: uniqueIndex("lancamentos_ofx_fit_id_user_id_idx")
.on(table.userId, table.ofxFitId)
.where(sql`ofx_fit_id IS NOT NULL`),
// Dedup OFX: identifica a transação completa sem assumir FITID único
ofxImportFingerprintUserIdIdx: uniqueIndex(
"lancamentos_ofx_import_fingerprint_user_id_idx",
)
.on(table.userId, table.ofxImportFingerprint)
.where(sql`ofx_import_fingerprint IS NOT NULL`),
}),
);

View File

@@ -0,0 +1,103 @@
import { headers } from "next/headers";
import Link from "next/link";
import { Suspense } from "react";
import { AnimatedThemeToggler } from "@/shared/components/animated-theme-toggler";
import { NavbarShell } from "@/shared/components/navigation/navbar/navbar-shell";
import { Button } from "@/shared/components/ui/button";
import { getOptionalUserSession } from "@/shared/lib/auth/server";
import { isSignupDisabled } from "@/shared/lib/auth/signup";
import { navLinks } from "../constants";
import { MobileNav } from "./mobile-nav";
async function LandingNavbarControls() {
const [session, headersList] = await Promise.all([
getOptionalUserSession(),
headers(),
]);
const hostname = headersList.get("host")?.replace(/:\d+$/, "");
const publicDomain = process.env.PUBLIC_DOMAIN?.replace(
/^https?:\/\//,
"",
).replace(/:\d+$/, "");
const isPublicDomain = !!(publicDomain && hostname === publicDomain);
const signupDisabled = isSignupDisabled();
return (
<>
{!isPublicDomain &&
(session?.user ? (
<Link prefetch href="/dashboard" className="hidden md:block">
<Button
variant="navbar"
size="sm"
className="h-9 text-primary-foreground/75 hover:bg-primary-foreground/10 hover:text-primary-foreground shadow-none dark:text-white/75 dark:hover:bg-white/10 dark:hover:text-white"
>
Dashboard
</Button>
</Link>
) : (
<div className="hidden md:flex items-center gap-1">
<Link href="/login">
<Button
variant="ghost"
size="sm"
className="h-9 text-primary-foreground/75 hover:bg-primary-foreground/10 hover:text-primary-foreground shadow-none dark:text-white/75 dark:hover:bg-white/10 dark:hover:text-white"
>
Entrar
</Button>
</Link>
{!signupDisabled && (
<Link href="/signup">
<Button
variant="ghost"
size="sm"
className="h-9 text-primary-foreground/75 hover:bg-primary-foreground/10 hover:text-primary-foreground shadow-none dark:text-white/75 dark:hover:bg-white/10 dark:hover:text-white"
>
Começar
</Button>
</Link>
)}
</div>
))}
<MobileNav
isPublicDomain={isPublicDomain}
isLoggedIn={!!session?.user}
signupDisabled={signupDisabled}
/>
</>
);
}
function LandingNavbarControlsFallback() {
return (
<>
<div className="hidden h-9 w-36 md:block" aria-hidden="true" />
<MobileNav isPublicDomain isLoggedIn={false} signupDisabled />
</>
);
}
export function LandingNavbar() {
return (
<NavbarShell>
<nav className="hidden md:flex items-center gap-1 absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
{navLinks.map(({ href, label }) => (
<Link
key={href}
href={href}
className="inline-flex h-9 items-center justify-center rounded-md px-2 text-sm font-medium leading-none text-primary-foreground/75 transition-colors hover:bg-primary-foreground/10 hover:text-primary-foreground dark:text-white/75 dark:hover:bg-white/10 dark:hover:text-white"
>
{label}
</Link>
))}
</nav>
<nav className="ml-auto flex items-center gap-1">
<AnimatedThemeToggler variant="navbar" />
<Suspense fallback={<LandingNavbarControlsFallback />}>
<LandingNavbarControls />
</Suspense>
</nav>
</NavbarShell>
);
}

View File

@@ -1,11 +1,22 @@
import { cacheLife } from "next/cache";
export async function getLandingCopyrightYear(): Promise<number> {
"use cache";
cacheLife({ revalidate: 86_400 });
return new Date().getFullYear();
}
export async function fetchGitHubStats(): Promise<{
stars: number;
forks: number;
}> {
"use cache";
cacheLife({ revalidate: 3600 });
try {
const res = await fetch(
"https://api.github.com/repos/felipegcoutinho/openmonetis",
{ next: { revalidate: 3600 } },
);
if (!res.ok) return { stars: 200, forks: 60 };
const data = await res.json();

View File

@@ -1,6 +1,6 @@
"use server";
import { and, eq, inArray } from "drizzle-orm";
import { and, eq, inArray, isNull, sql } from "drizzle-orm";
import { z } from "zod";
import { transactions } from "@/db/schema";
import {
@@ -9,23 +9,49 @@ import {
validateCartaoOwnership,
validateContaOwnership,
} from "@/features/transactions/actions/core";
import { createOfxImportFingerprint } from "@/features/transactions/lib/ofx-import-fingerprint";
import { revalidateForEntity } from "@/shared/lib/actions/helpers";
import { getUserId } from "@/shared/lib/auth/server";
import { db } from "@/shared/lib/db";
import {
normalizeOfxIdentityText,
type OfxIdentityRow,
type OfxImportDestination,
} from "@/shared/lib/import/ofx-identity";
import { uuidSchema } from "@/shared/lib/schemas/common";
import { parseLocalDateString } from "@/shared/utils/date";
import { formatDecimalForDbRequired } from "@/shared/utils/currency";
import { parseLocalDateString, toDateOnlyString } from "@/shared/utils/date";
const importRowSchema = z.object({
const ofxIdentityRowSchema = z.object({
externalId: z.string().nullable(),
externalIdOccurrence: z.number().int().nonnegative(),
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Data inválida."),
amount: z.number().positive(),
description: z.string().min(1, "Descrição obrigatória."),
transactionType: z.enum(["income", "expense"]),
sourceDescription: z.string(),
});
const ofxImportDestinationSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("account"), id: uuidSchema("Conta") }),
z.object({ type: z.literal("card"), id: uuidSchema("Cartão") }),
]);
const duplicateCheckSchema = z.object({
source: z.string().min(1),
accountNumber: z.string().nullable(),
destination: ofxImportDestinationSchema,
rows: z.array(ofxIdentityRowSchema),
});
const importRowSchema = ofxIdentityRowSchema.extend({
description: z.string().min(1, "Descrição obrigatória."),
categoryId: uuidSchema("Category").nullable().optional(),
payerId: uuidSchema("Payer").nullable().optional(),
});
const importSchema = z.object({
source: z.string().min(1),
accountNumber: z.string().nullable(),
rows: z.array(importRowSchema).min(1, "Selecione ao menos uma transação."),
payerId: uuidSchema("Payer").nullable().optional(),
accountId: uuidSchema("FinancialAccount").nullable().optional(),
@@ -44,22 +70,197 @@ type ImportResult =
| { success: true; imported: number; skipped: number; importBatchId: string }
| { success: false; error: string };
// Retorna os externalIds que já existem para o usuário (para marcar duplicatas)
export async function checkDuplicateFitIds(
fitIds: string[],
): Promise<string[]> {
const userId = await getUserId();
const ids = fitIds.filter(Boolean);
if (ids.length === 0) return [];
type ImportMatch = {
fingerprint: string | null;
existingTransactionId: string | null;
};
const rows = await db
.select({ ofxFitId: transactions.ofxFitId })
.from(transactions)
.where(
and(eq(transactions.userId, userId), inArray(transactions.ofxFitId, ids)),
type LegacyImportCandidate = {
id: string;
name: string;
amount: string;
purchaseDate: Date;
transactionType: string;
ofxFitId: string | null;
accountId: string | null;
cardId: string | null;
};
function isDestinationMatch(
candidate: LegacyImportCandidate,
destination: OfxImportDestination,
): boolean {
return destination.type === "card"
? candidate.cardId === destination.id
: candidate.accountId === destination.id;
}
function isLegacyImportMatch(
candidate: LegacyImportCandidate,
row: OfxIdentityRow,
destination: OfxImportDestination,
): boolean {
if (
!row.externalId ||
row.externalIdOccurrence !== 0 ||
candidate.ofxFitId !== row.externalId ||
!isDestinationMatch(candidate, destination)
) {
return false;
}
const expectedType = row.transactionType === "income" ? "Receita" : "Despesa";
const signedAmount =
row.transactionType === "expense" ? -row.amount : row.amount;
return (
toDateOnlyString(candidate.purchaseDate) === row.date &&
formatDecimalForDbRequired(Number(candidate.amount)) ===
formatDecimalForDbRequired(signedAmount) &&
candidate.transactionType === expectedType &&
normalizeOfxIdentityText(candidate.name) ===
normalizeOfxIdentityText(row.sourceDescription)
);
}
async function findExistingImports(
userId: string,
source: string,
accountNumber: string | null,
destination: OfxImportDestination,
rows: OfxIdentityRow[],
): Promise<ImportMatch[]> {
const fingerprints = rows.map((row) =>
createOfxImportFingerprint({
source,
accountNumber,
destination,
row,
}),
);
const fingerprintValues = [
...new Set(
fingerprints.filter(
(fingerprint): fingerprint is string => fingerprint !== null,
),
),
];
const fitIds = [
...new Set(
rows
.map((row) => row.externalId)
.filter((fitId): fitId is string => fitId !== null),
),
];
const [fingerprintMatches, legacyCandidates] = await Promise.all([
fingerprintValues.length > 0
? db
.select({
id: transactions.id,
fingerprint: transactions.ofxImportFingerprint,
})
.from(transactions)
.where(
and(
eq(transactions.userId, userId),
inArray(transactions.ofxImportFingerprint, fingerprintValues),
),
)
: Promise.resolve([]),
fitIds.length > 0
? db
.select({
id: transactions.id,
name: transactions.name,
amount: transactions.amount,
purchaseDate: transactions.purchaseDate,
transactionType: transactions.transactionType,
ofxFitId: transactions.ofxFitId,
accountId: transactions.accountId,
cardId: transactions.cardId,
})
.from(transactions)
.where(
and(
eq(transactions.userId, userId),
isNull(transactions.ofxImportFingerprint),
inArray(transactions.ofxFitId, fitIds),
),
)
: Promise.resolve([]),
]);
const transactionIdByFingerprint = new Map(
fingerprintMatches.flatMap((match) =>
match.fingerprint ? [[match.fingerprint, match.id] as const] : [],
),
);
const consumedLegacyIds = new Set<string>();
return rows.map((row, index) => {
const fingerprint = fingerprints[index] ?? null;
const currentTransactionId = fingerprint
? (transactionIdByFingerprint.get(fingerprint) ?? null)
: null;
if (currentTransactionId) {
return { fingerprint, existingTransactionId: currentTransactionId };
}
const legacyMatch = legacyCandidates.find(
(candidate) =>
!consumedLegacyIds.has(candidate.id) &&
isLegacyImportMatch(candidate, row, destination),
);
return rows.map((r) => r.ofxFitId).filter((id): id is string => id !== null);
if (legacyMatch) {
consumedLegacyIds.add(legacyMatch.id);
}
return {
fingerprint,
existingTransactionId: legacyMatch?.id ?? null,
};
});
}
async function validateDestinationOwnership(
userId: string,
destination: OfxImportDestination,
): Promise<boolean> {
return destination.type === "card"
? validateCartaoOwnership(userId, destination.id)
: validateContaOwnership(userId, destination.id);
}
export async function checkDuplicateOfxTransactions(
input: unknown,
): Promise<
{ success: true; rows: ImportMatch[] } | { success: false; error: string }
> {
const userId = await getUserId();
const parsed = duplicateCheckSchema.safeParse(input);
if (!parsed.success) {
return { success: false, error: "Dados do arquivo inválidos." };
}
const { source, accountNumber, destination, rows } = parsed.data;
if (!(await validateDestinationOwnership(userId, destination))) {
return { success: false, error: "Conta ou cartão não encontrado." };
}
return {
success: true,
rows: await findExistingImports(
userId,
source,
accountNumber,
destination,
rows,
),
};
}
export async function importTransactionsAction(
@@ -75,8 +276,25 @@ export async function importTransactionsAction(
};
}
const { rows, payerId, accountId, cardId, paymentMethod, invoicePeriod } =
parsed.data;
const {
source,
accountNumber,
rows,
payerId,
accountId,
cardId,
paymentMethod,
invoicePeriod,
} = parsed.data;
const destination: OfxImportDestination | null = cardId
? { type: "card", id: cardId }
: accountId
? { type: "account", id: accountId }
: null;
if (!destination || (accountId && cardId)) {
return { success: false, error: "Selecione uma conta ou cartão." };
}
const payerIdsByRow = rows.map((row) => row.payerId ?? payerId ?? null);
@@ -109,8 +327,33 @@ export async function importTransactionsAction(
if (!accountOk) return { success: false, error: "Conta não encontrada." };
if (!cardOk) return { success: false, error: "Cartão não encontrado." };
if (rows.length === 0) {
return { success: true, imported: 0, skipped: 0, importBatchId: "" };
const importMatches = await findExistingImports(
userId,
source,
accountNumber,
destination,
rows,
);
const rowsToImport = rows.flatMap((row, index) => {
const match = importMatches[index];
return match?.existingTransactionId
? []
: [
{
row,
payerId: payerIdsByRow[index],
fingerprint: match?.fingerprint ?? null,
},
];
});
if (rowsToImport.length === 0) {
return {
success: true,
imported: 0,
skipped: rows.length,
importBatchId: "",
};
}
const importBatchId = crypto.randomUUID();
@@ -118,7 +361,7 @@ export async function importTransactionsAction(
// Cartão de crédito: fatura pode ainda não ter sido paga
const isSettled = paymentMethod !== "Cartão de crédito";
const records = rows.map((row, index) => {
const records = rowsToImport.map(({ row, payerId, fingerprint }) => {
const purchaseDate = parseLocalDateString(row.date);
const period =
invoicePeriod ??
@@ -137,21 +380,24 @@ export async function importTransactionsAction(
period,
isSettled,
userId,
payerId: payerIdsByRow[index],
payerId,
accountId: accountId ?? null,
cardId: cardId ?? null,
categoryId: row.categoryId ?? null,
ofxFitId: row.externalId,
ofxImportFingerprint: fingerprint,
importBatchId,
};
});
// onConflictDoNothing usa o uniqueIndex (userId, ofxFitId) WHERE ofxFitId IS NOT NULL
// eliminando o SELECT prévio de checkDuplicateFitIds
// O índice de fingerprint protege contra importações concorrentes do mesmo OFX.
const inserted = await db
.insert(transactions)
.values(records)
.onConflictDoNothing()
.onConflictDoNothing({
target: [transactions.userId, transactions.ofxImportFingerprint],
where: sql`ofx_import_fingerprint IS NOT NULL`,
})
.returning({ id: transactions.id });
await revalidateForEntity("transactions", userId);
@@ -159,23 +405,31 @@ export async function importTransactionsAction(
return {
success: true,
imported: inserted.length,
skipped: records.length - inserted.length,
skipped: rows.length - inserted.length,
importBatchId,
};
}
export async function deleteTransactionByFitId(
fitId: string,
export async function deleteImportedTransaction(
transactionId: string,
): Promise<{ success: boolean; error?: string }> {
if (!fitId) return { success: false, error: "FITID inválido." };
const parsedId = uuidSchema("Lançamento").safeParse(transactionId);
if (!parsedId.success) {
return { success: false, error: "Lançamento inválido." };
}
const userId = await getUserId();
await db
const deleted = await db
.delete(transactions)
.where(
and(eq(transactions.userId, userId), eq(transactions.ofxFitId, fitId)),
);
and(eq(transactions.userId, userId), eq(transactions.id, parsedId.data)),
)
.returning({ id: transactions.id });
if (deleted.length === 0) {
return { success: false, error: "Lançamento não encontrado." };
}
await revalidateForEntity("transactions", userId);

View File

@@ -1,21 +1,15 @@
"use client";
import { useRouter } from "next/navigation";
import {
useCallback,
useEffect,
useMemo,
useState,
useTransition,
} from "react";
import { useCallback, useMemo, useRef, useState, useTransition } from "react";
import { toast } from "sonner";
import {
fetchCategoryMappings,
saveCategoryMappings,
} from "@/features/transactions/actions/category-memory-action";
import {
checkDuplicateFitIds,
deleteTransactionByFitId,
checkDuplicateOfxTransactions,
deleteImportedTransaction,
importTransactionsAction,
undoImportAction,
} from "@/features/transactions/actions/import-action";
@@ -43,6 +37,7 @@ import {
} from "@/shared/components/ui/card";
import { Skeleton } from "@/shared/components/ui/skeleton";
import type { ImportStatement } from "@/shared/lib/import/types";
import { createClientSafeId } from "@/shared/utils/id";
const categoryGroupByTransactionType = {
expense: "despesa",
@@ -69,6 +64,7 @@ export function ImportPage({
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [isChecking, setIsChecking] = useState(false);
const duplicateCheckRequestId = useRef(0);
const [statement, setStatement] = useState<ImportStatement | null>(null);
const [rows, setRows] = useState<ReviewRow[]>([]);
@@ -95,23 +91,45 @@ export function ImportPage({
const handleParsed = useCallback(
async (stmt: ImportStatement) => {
const requestId = ++duplicateCheckRequestId.current;
setStatement(stmt);
setIsChecking(true);
const defaultAccountCardValue = stmt.isCreditCard
? cardOptions[0]
? encodeAccountCard("card", cardOptions[0].value)
: null
: accountOptions[0]
? encodeAccountCard("account", accountOptions[0].value)
: null;
const destination = defaultAccountCardValue
? decodeAccountCard(defaultAccountCardValue)
: null;
setAccountCardValue(defaultAccountCardValue);
try {
const fitIds = stmt.transactions
.map((t) => t.externalId)
.filter((id): id is string => id !== null);
const [duplicates, categoryMappings] = await Promise.all([
checkDuplicateFitIds(fitIds).then((ids) => new Set(ids)),
const [duplicateResult, categoryMappings] = await Promise.all([
destination
? checkDuplicateOfxTransactions({
source: stmt.source,
accountNumber: stmt.accountNumber,
destination,
rows: stmt.transactions,
})
: Promise.resolve({ success: true as const, rows: [] }),
fetchCategoryMappings(stmt.transactions.map((t) => t.description)),
]);
if (requestId !== duplicateCheckRequestId.current) return;
if (!duplicateResult.success) {
toast.error(duplicateResult.error);
}
setRows(
stmt.transactions.map((t) => {
stmt.transactions.map((t, index) => {
let mappedCategoryId =
categoryMappings[normalizeDescriptionKey(t.description)] ?? null;
const existingTransactionId = duplicateResult.success
? (duplicateResult.rows[index]?.existingTransactionId ?? null)
: null;
if (t.categoryRaw) {
const categoryRaw = normalizeCategoryName(t.categoryRaw);
@@ -125,8 +143,10 @@ export function ImportPage({
return {
...t,
isDuplicate: t.externalId ? duplicates.has(t.externalId) : false,
selected: t.externalId ? !duplicates.has(t.externalId) : true,
reviewId: createClientSafeId(),
existingTransactionId,
isDuplicate: existingTransactionId !== null,
selected: existingTransactionId === null,
payerId,
categoryId: isCategoryCompatible(
mappedCategoryId,
@@ -138,23 +158,64 @@ export function ImportPage({
}),
);
} finally {
setIsChecking(false);
if (requestId === duplicateCheckRequestId.current) {
setIsChecking(false);
}
}
},
[isCategoryCompatible, payerId, categoryOptions],
[
accountOptions,
cardOptions,
categoryOptions,
isCategoryCompatible,
payerId,
],
);
// Pré-seleciona cartão ou conta com base no tipo detectado no OFX
useEffect(() => {
if (!statement || accountCardValue) return;
if (statement.isCreditCard && cardOptions[0]) {
setAccountCardValue(encodeAccountCard("card", cardOptions[0].value));
} else if (!statement.isCreditCard && accountOptions[0]) {
setAccountCardValue(
encodeAccountCard("account", accountOptions[0].value),
);
const handleAccountCardChange = async (value: string | null) => {
const requestId = ++duplicateCheckRequestId.current;
setAccountCardValue(value);
const destination = value ? decodeAccountCard(value) : null;
if (!statement || !destination) {
setIsChecking(false);
return;
}
}, [statement, cardOptions, accountOptions, accountCardValue]);
setIsChecking(true);
try {
const result = await checkDuplicateOfxTransactions({
source: statement.source,
accountNumber: statement.accountNumber,
destination,
rows,
});
if (requestId !== duplicateCheckRequestId.current) return;
if (!result.success) {
toast.error(result.error);
return;
}
setRows((previousRows) =>
previousRows.map((row, index) => {
const existingTransactionId =
result.rows[index]?.existingTransactionId ?? null;
const isDuplicate = existingTransactionId !== null;
return {
...row,
existingTransactionId,
selected:
row.isDuplicate === isDuplicate ? row.selected : !isDuplicate,
isDuplicate,
};
}),
);
} finally {
if (requestId === duplicateCheckRequestId.current) {
setIsChecking(false);
}
}
};
const toggleRow = (index: number) => {
setRows((prev) =>
@@ -184,9 +245,9 @@ export function ImportPage({
const handleUndoDuplicate = async (index: number) => {
const row = rows[index];
if (!row?.externalId) return;
if (!row?.existingTransactionId) return;
const result = await deleteTransactionByFitId(row.externalId);
const result = await deleteImportedTransaction(row.existingTransactionId);
if (!result.success) {
toast.error("Não foi possível desfazer a importação anterior.");
return;
@@ -194,7 +255,14 @@ export function ImportPage({
setRows((prev) =>
prev.map((r, i) =>
i === index ? { ...r, isDuplicate: false, selected: true } : r,
i === index
? {
...r,
existingTransactionId: null,
isDuplicate: false,
selected: true,
}
: r,
),
);
toast.success("Importação anterior removida.");
@@ -261,11 +329,15 @@ export function ImportPage({
startTransition(async () => {
const result = await importTransactionsAction({
source: statement.source,
accountNumber: statement.accountNumber,
rows: selectedRows.map((r) => ({
externalId: r.externalId,
externalIdOccurrence: r.externalIdOccurrence,
date: r.date,
amount: r.amount,
description: r.description,
sourceDescription: r.sourceDescription,
transactionType: r.transactionType,
categoryId: r.categoryId,
payerId: r.payerId,
@@ -369,7 +441,7 @@ export function ImportPage({
accountCardValue={accountCardValue}
payerId={payerId}
invoicePeriod={invoicePeriod}
onAccountCardChange={setAccountCardValue}
onAccountCardChange={handleAccountCardChange}
onPayerChange={handleBulkPayerChange}
onInvoicePeriodChange={setInvoicePeriod}
onBulkCategoryChange={handleBulkCategoryChange}

View File

@@ -43,8 +43,10 @@ const categoryGroupByTransactionType: Record<
};
export type ReviewRow = ImportedTransaction & {
reviewId: string;
selected: boolean;
isDuplicate: boolean;
existingTransactionId: string | null;
categoryId: string | null;
payerId: string | null;
};
@@ -80,6 +82,7 @@ export function ReviewTable({
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => parentRef.current,
getItemKey: (index) => rows[index]?.reviewId ?? index,
estimateSize: () => 44,
overscan: 8,
});
@@ -141,7 +144,7 @@ export function ReviewTable({
);
return (
<TableRow
key={row.externalId ?? `${row.date}-${index}`}
key={row.reviewId}
className={
row.isDuplicate && !row.selected ? "opacity-50" : ""
}

View File

@@ -0,0 +1,24 @@
import "server-only";
import { createHash } from "node:crypto";
import {
buildOfxFingerprintPayload,
type OfxIdentityRow,
type OfxImportDestination,
} from "@/shared/lib/import/ofx-identity";
type CreateOfxImportFingerprintInput = {
source: string;
accountNumber: string | null;
destination: OfxImportDestination;
row: OfxIdentityRow;
};
export function createOfxImportFingerprint(
input: CreateOfxImportFingerprintInput,
): string | null {
const payload = buildOfxFingerprintPayload(input);
if (!payload) return null;
return createHash("sha256").update(payload).digest("hex");
}

View File

@@ -0,0 +1,38 @@
"use client";
import { RiErrorWarningLine } from "@remixicon/react";
import { catchError, type ErrorInfo } from "next/error";
import { EmptyState } from "@/shared/components/feedback/empty-state";
import { Button } from "@/shared/components/ui/button";
type ContentErrorBoundaryProps = {
title: string;
description: string;
};
function ContentErrorFallback(
{ title, description }: ContentErrorBoundaryProps,
{ retry }: ErrorInfo,
) {
return (
<div
role="alert"
className="flex min-h-64 w-full items-center justify-center"
>
<EmptyState
title={title}
description={description}
media={<RiErrorWarningLine className="size-5 text-destructive" />}
mediaVariant="icon"
className="min-h-64 border border-dashed"
>
<Button type="button" variant="outline" onClick={retry}>
Tentar novamente
</Button>
</EmptyState>
</div>
);
}
export const ContentErrorBoundary =
catchError<ContentErrorBoundaryProps>(ContentErrorFallback);

View File

@@ -0,0 +1,69 @@
import { formatDecimalForDbRequired } from "@/shared/utils/currency";
export type OfxImportDestination = {
type: "account" | "card";
id: string;
};
export type OfxIdentityRow = {
externalId: string | null;
externalIdOccurrence: number;
date: string;
amount: number;
transactionType: "income" | "expense";
sourceDescription: string;
};
type OfxIdentityInput = {
source: string;
accountNumber: string | null;
destination: OfxImportDestination;
row: OfxIdentityRow;
};
export function normalizeOfxIdentityText(value: string): string {
return value.normalize("NFKC").trim().replace(/\s+/g, " ").toLowerCase();
}
export function buildOfxOccurrenceKey(
row: Omit<OfxIdentityRow, "externalIdOccurrence">,
): string | null {
if (!row.externalId) return null;
const signedAmount =
row.transactionType === "expense" ? -row.amount : row.amount;
return JSON.stringify([
row.externalId.trim(),
row.date,
formatDecimalForDbRequired(signedAmount),
row.transactionType,
normalizeOfxIdentityText(row.sourceDescription),
]);
}
export function buildOfxFingerprintPayload({
source,
accountNumber,
destination,
row,
}: OfxIdentityInput): string | null {
if (!row.externalId) return null;
const signedAmount =
row.transactionType === "expense" ? -row.amount : row.amount;
return JSON.stringify([
"openmonetis-ofx-v1",
normalizeOfxIdentityText(source),
normalizeOfxIdentityText(accountNumber ?? ""),
destination.type,
destination.id,
row.externalId.trim(),
row.date,
formatDecimalForDbRequired(signedAmount),
row.transactionType,
normalizeOfxIdentityText(row.sourceDescription),
row.externalIdOccurrence,
]);
}

View File

@@ -1,3 +1,4 @@
import { buildOfxOccurrenceKey } from "./ofx-identity";
import type { ImportedTransaction, ImportStatement } from "./types";
// Extrai o valor de uma tag leaf do OFX SGML: <TAG>valor
@@ -32,6 +33,7 @@ export function parseOfx(content: string): ImportStatement {
// Transações
const blocks = xml.match(/<STMTTRN>[\s\S]*?<\/STMTTRN>/g) ?? [];
const occurrenceCounts = new Map<string, number>();
const transactions: ImportedTransaction[] = blocks.map((block) => {
const trnType = getField(block, "TRNTYPE") ?? "DEBIT";
const dtPosted = getField(block, "DTPOSTED") ?? "";
@@ -41,16 +43,30 @@ export function parseOfx(content: string): ImportStatement {
const name = getField(block, "NAME");
const amount = Number.parseFloat(trnAmt.replace(",", "."));
const transactionType =
const transactionType: ImportedTransaction["transactionType"] =
amount > 0 || trnType === "CREDIT" ? "income" : "expense";
return {
const description = memo ?? name ?? "";
const transaction = {
externalId: fitId,
date: parseOfxDate(dtPosted),
amount: Math.abs(amount),
description: memo ?? name ?? "",
description,
sourceDescription: description,
transactionType,
};
const occurrenceKey = buildOfxOccurrenceKey(transaction);
const externalIdOccurrence = occurrenceKey
? (occurrenceCounts.get(occurrenceKey) ?? 0)
: 0;
if (occurrenceKey) {
occurrenceCounts.set(occurrenceKey, externalIdOccurrence + 1);
}
return {
...transaction,
externalIdOccurrence,
};
});
const isCreditCard = xml.includes("<CREDITCARDMSGSRSV1>");

View File

@@ -1,8 +1,10 @@
export type ImportedTransaction = {
externalId: string | null; // FITID do OFX
externalIdOccurrence: number; // posição entre registros OFX idênticos
date: string; // YYYY-MM-DD
amount: number; // positivo = receita, negativo = despesa
description: string; // MEMO ou NAME
sourceDescription: string; // descrição original, preservada para deduplicação
transactionType: "income" | "expense";
categoryRaw?: string | null;
};

View File

@@ -105,9 +105,11 @@ export async function parseXls(buffer: ArrayBuffer): Promise<ImportStatement> {
transactions.push({
externalId: null,
externalIdOccurrence: 0,
date,
amount,
description,
sourceDescription: description,
transactionType,
categoryRaw,
});