From 77ebbd6af8a733db980a37964383c33d609fff41 Mon Sep 17 00:00:00 2001 From: Gabriel Chioquetta Date: Sat, 3 Oct 2026 22:57:53 -0300 Subject: [PATCH 1/4] feat: add investment portfolio with Supabase auth, multi-asset tracking, and projections Transform FinForge from a calculator suite into a full investment portfolio app. Backend & Infrastructure - Add Supabase setup (@supabase/supabase-js, @supabase/ssr) - Create SQL migration with 5 tables (profiles, assets, fixed_income_details, transactions, price_cache) + RLS policies + auto-profile trigger - Add supabase/migrations/20251003000000_init.sql Authentication - Login/signup/logout pages with Server Actions - useAuth hook and UserMenu component - Middleware for session refresh Domain & Services (with 39 new Vitest tests) - portfolio.service: average cost, realized/unrealized P&L, partial sells - fixedIncome.service: CDI%, IPCA+, prefixado, maturity - projection.service: month-by-month with contribution/withdrawal/IPCA - allocation.service: diversification + concentration warnings - dividends.service: yield on cost, by month/by asset - priceFetcher.service: CoinGecko + Brapi + Yahoo Finance + AwesomeAPI with 15min cache in price_cache table - currency.service + portfolio-aggregator: BRL+USD aggregation Routes & UI - /login, /signup - /portfolio with sub-pages: holdings, allocation, projection, simulator, dividends, settings - /assets CRUD (stock/etf/fii/crypto/fixed_income/cash) - /transactions CRUD (buy/sell/dividend/income) - Shared PortfolioShell with sidebar + mobile tabs - AssetForm with 6 asset types including FI details - TransactionForm with 4 transaction types - RefreshPricesButton + /api/prices/refresh route - Backup/Restore JSON (export/import) - PortfolioTeaser on dashboard for logged-in users Calculator suite preserved and accessible from dashboard. UI fixes - Complete Tailwind v4 @theme color mapping (popover, accent, muted, secondary, destructive, border, input, ring, sidebar) - Fix Select component transparency with inline style fallback - Fix Recharts in server components (extract to client components) - Fix Supabase middleware to return Response not object - Fix native select bg-transparent across forms --- README.md | 184 ++++------ app/(auth)/actions.ts | 66 ++++ app/(auth)/layout.tsx | 19 + app/(auth)/login/page.tsx | 9 + app/(auth)/signup/page.tsx | 9 + app/api/prices/refresh/route.ts | 76 ++++ app/assets/[id]/page.tsx | 104 ++++++ app/assets/actions.ts | 208 +++++++++++ app/assets/layout.tsx | 10 + app/assets/new/page.tsx | 27 ++ app/assets/page.tsx | 98 +++++ app/globals.css | 46 +++ app/page.tsx | 10 +- app/portfolio/allocation/page.tsx | 73 ++++ app/portfolio/dividends/page.tsx | 193 ++++++++++ app/portfolio/holdings/page.tsx | 302 +++++++++++++++ app/portfolio/layout.tsx | 10 + app/portfolio/page.tsx | 109 ++++++ app/portfolio/projection/page.tsx | 46 +++ app/portfolio/settings/actions.ts | 198 ++++++++++ app/portfolio/settings/page.tsx | 15 + app/portfolio/simulator/page.tsx | 86 +++++ app/transactions/actions.ts | 108 ++++++ app/transactions/layout.tsx | 10 + app/transactions/new/page.tsx | 27 ++ app/transactions/page.tsx | 219 +++++++++++ middleware.ts | 13 + package-lock.json | 135 +++++++ package.json | 3 + src/components/CalculatorLayout.tsx | 2 + src/components/Dashboard/PortfolioTeaser.tsx | 184 ++++++++++ src/components/Dashboard/index.tsx | 2 + src/components/auth/AuthForm.tsx | 169 +++++++++ src/components/auth/UserMenu.tsx | 109 ++++++ src/components/portfolio/AllocationView.tsx | 201 ++++++++++ src/components/portfolio/AssetForm.tsx | 301 +++++++++++++++ src/components/portfolio/DividendsChart.tsx | 68 ++++ .../portfolio/PortfolioSettings.tsx | 131 +++++++ src/components/portfolio/PortfolioShell.tsx | 48 +++ src/components/portfolio/PortfolioSidebar.tsx | 99 +++++ src/components/portfolio/PortfolioTabs.tsx | 46 +++ src/components/portfolio/ProjectionPanel.tsx | 344 ++++++++++++++++++ .../portfolio/RefreshPricesButton.tsx | 35 ++ src/components/portfolio/Simulator.tsx | 307 ++++++++++++++++ src/components/portfolio/TransactionForm.tsx | 246 +++++++++++++ src/components/ui/badge.tsx | 31 ++ src/components/ui/button.tsx | 135 ++++--- src/components/ui/select.tsx | 16 +- src/core/data/portfolio-data.ts | 65 ++++ src/core/domain/asset-classes.ts | 63 ++++ src/core/domain/portfolio-constants.ts | 38 ++ src/core/domain/portfolio.types.ts | 79 ++++ src/core/services/allocation.service.ts | 85 +++++ src/core/services/currency.service.ts | 50 +++ src/core/services/dividends.service.ts | 134 +++++++ src/core/services/fixedIncome.service.ts | 77 ++++ .../services/portfolio-aggregator.service.ts | 62 ++++ src/core/services/portfolio.service.ts | 173 +++++++++ src/core/services/priceFetcher.service.ts | 195 ++++++++++ src/core/services/projection.service.ts | 114 ++++++ src/core/utils/currency-format.ts | 36 ++ src/hooks/useAuth.ts | 35 ++ src/lib/supabase/client.ts | 9 + src/lib/supabase/middleware.ts | 33 ++ src/lib/supabase/server.ts | 28 ++ src/lib/supabase/types.ts | 197 ++++++++++ src/test/allocation.service.test.ts | 65 ++++ src/test/dividends.service.test.ts | 75 ++++ src/test/fixedIncome.service.test.ts | 73 ++++ src/test/portfolio.service.test.ts | 197 ++++++++++ src/test/projection.service.test.ts | 73 ++++ supabase/README.md | 21 ++ supabase/migrations/20251003000000_init.sql | 224 ++++++++++++ 73 files changed, 6917 insertions(+), 171 deletions(-) create mode 100644 app/(auth)/actions.ts create mode 100644 app/(auth)/layout.tsx create mode 100644 app/(auth)/login/page.tsx create mode 100644 app/(auth)/signup/page.tsx create mode 100644 app/api/prices/refresh/route.ts create mode 100644 app/assets/[id]/page.tsx create mode 100644 app/assets/actions.ts create mode 100644 app/assets/layout.tsx create mode 100644 app/assets/new/page.tsx create mode 100644 app/assets/page.tsx create mode 100644 app/portfolio/allocation/page.tsx create mode 100644 app/portfolio/dividends/page.tsx create mode 100644 app/portfolio/holdings/page.tsx create mode 100644 app/portfolio/layout.tsx create mode 100644 app/portfolio/page.tsx create mode 100644 app/portfolio/projection/page.tsx create mode 100644 app/portfolio/settings/actions.ts create mode 100644 app/portfolio/settings/page.tsx create mode 100644 app/portfolio/simulator/page.tsx create mode 100644 app/transactions/actions.ts create mode 100644 app/transactions/layout.tsx create mode 100644 app/transactions/new/page.tsx create mode 100644 app/transactions/page.tsx create mode 100644 middleware.ts create mode 100644 src/components/Dashboard/PortfolioTeaser.tsx create mode 100644 src/components/auth/AuthForm.tsx create mode 100644 src/components/auth/UserMenu.tsx create mode 100644 src/components/portfolio/AllocationView.tsx create mode 100644 src/components/portfolio/AssetForm.tsx create mode 100644 src/components/portfolio/DividendsChart.tsx create mode 100644 src/components/portfolio/PortfolioSettings.tsx create mode 100644 src/components/portfolio/PortfolioShell.tsx create mode 100644 src/components/portfolio/PortfolioSidebar.tsx create mode 100644 src/components/portfolio/PortfolioTabs.tsx create mode 100644 src/components/portfolio/ProjectionPanel.tsx create mode 100644 src/components/portfolio/RefreshPricesButton.tsx create mode 100644 src/components/portfolio/Simulator.tsx create mode 100644 src/components/portfolio/TransactionForm.tsx create mode 100644 src/components/ui/badge.tsx create mode 100644 src/core/data/portfolio-data.ts create mode 100644 src/core/domain/asset-classes.ts create mode 100644 src/core/domain/portfolio-constants.ts create mode 100644 src/core/domain/portfolio.types.ts create mode 100644 src/core/services/allocation.service.ts create mode 100644 src/core/services/currency.service.ts create mode 100644 src/core/services/dividends.service.ts create mode 100644 src/core/services/fixedIncome.service.ts create mode 100644 src/core/services/portfolio-aggregator.service.ts create mode 100644 src/core/services/portfolio.service.ts create mode 100644 src/core/services/priceFetcher.service.ts create mode 100644 src/core/services/projection.service.ts create mode 100644 src/core/utils/currency-format.ts create mode 100644 src/hooks/useAuth.ts create mode 100644 src/lib/supabase/client.ts create mode 100644 src/lib/supabase/middleware.ts create mode 100644 src/lib/supabase/server.ts create mode 100644 src/lib/supabase/types.ts create mode 100644 src/test/allocation.service.test.ts create mode 100644 src/test/dividends.service.test.ts create mode 100644 src/test/fixedIncome.service.test.ts create mode 100644 src/test/portfolio.service.test.ts create mode 100644 src/test/projection.service.test.ts create mode 100644 supabase/README.md create mode 100644 supabase/migrations/20251003000000_init.sql diff --git a/README.md b/README.md index bc0f78e..331c9ee 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # FinForge 🔥 -> Suíte de ferramentas financeiras de alta precisão para planejamento de independência financeira (FIRE) e simulações de investimento. +> Carteira de investimentos pessoal com precisão decimal e projetor de patrimônio. ## 🎯 Sobre o Projeto @@ -8,134 +8,110 @@ FinForge é uma aplicação financeira construída com foco em **precisão matem ### ✨ Funcionalidades -- 📊 **Calculadora de Juros Compostos**: Simule o crescimento do seu patrimônio com aportes recorrentes -- 🔥 **Planejador FIRE**: Calcule quanto você precisa para se aposentar e viver de renda passiva -- 📈 **Gráficos Interativos**: Visualize a evolução do seu patrimônio ao longo do tempo -- 💯 **Precisão Absoluta**: Uso de `decimal.js` para cálculos sem erros de arredondamento - -## 🛠 Stack Tecnológica - -- **Framework**: Next.js 16.1 (App Router) -- **Engine**: React 19 (Server Components) +#### 💼 Carteira de Investimentos +- 📊 **Cadastro de ativos** com tipos: Ações, ETFs, FIIs, Cripto, Renda Fixa e Caixa +- 💸 **Transações** de compra, venda, dividendos, JCP e rendimentos +- 📈 **Posições** com preço médio, P&L realizado e não realizado +- 🎯 **Alocação** com gráfico de pizza + alertas de concentração +- 📉 **Projeção** de patrimônio futuro com aportes e IPCA +- 🧪 **Simulador "E se...?"** comparando cenários com aporte extra e retiradas +- 💵 **Proventos** com yield on cost por ativo +- 🌍 **Multi-moeda** (BRL + USD) com cotação automática +- 💾 **Backup / Restore** via JSON +- 🔐 **Auth** com Supabase (email/senha) + +#### 🧮 Calculadoras (mantidas) +- Juros Compostos, Juros Simples, FIRE, Conversor de Taxas, Ajuste de Inflação, Comparador de Investimentos + +### 🛠 Stack Tecnológica + +- **Framework**: Next.js 16.1 (App Router, Server Actions) +- **Engine**: React 19 - **Linguagem**: TypeScript 5.x (Strict mode) - **Estilização**: Tailwind CSS v4 -- **UI Components**: shadcn/ui (Radix UI) -- **Animações**: Framer Motion 12 +- **UI**: shadcn/ui (Radix UI) + Framer Motion 12 +- **Backend / DB**: Supabase (Postgres + Auth + RLS) - **Gráficos**: Recharts - **Matemática**: decimal.js - **Validação**: Zod - **Formulários**: React Hook Form -## 📁 Arquitetura - -O projeto segue os princípios de **Clean Architecture**, separando claramente: - -``` -src/ -├── core/ # 🎯 DOMÍNIO (Lógica Pura) -│ ├── domain/ # Entidades e tipos -│ ├── use-cases/ # Regras de negócio isoladas -│ └── utils/ # Helpers matemáticos -├── components/ # 🎨 UI Components -│ ├── ui/ # Componentes base (shadcn) -│ ├── charts/ # Componentes de visualização -│ └── dashboard/ # Componentes de negócio -├── hooks/ # Custom hooks -└── lib/ # Configurações e utilitários - -app/ # 📄 Camada de Apresentação (Next.js) -├── compound-interest/ # Calculadora de Juros Compostos -├── fire-planner/ # Planejador FIRE -└── page.tsx # Home page -``` - -### 🎯 Princípios da Arquitetura - -1. **Independência de Framework**: A lógica de negócio (core) é totalmente isolada do React/Next.js -2. **Testabilidade**: Use-cases puros podem ser testados sem UI -3. **Precisão**: Uso obrigatório de `Decimal` para todos os cálculos financeiros -4. **Type Safety**: TypeScript strict mode + validação com Zod +### 🔌 Cotações +- Cripto: **CoinGecko** (sem auth) +- Ações/FIIs/ETFs BR: **Brapi.dev** (free tier) +- Ações/ETFs US: **Yahoo Finance** (sem auth) +- USD/BRL: **AwesomeAPI** +- Cache compartilhado em `price_cache` (TTL 15min, refresh manual) ## 🚀 Como Executar ```bash -# Instalar dependências npm install - -# Executar em desenvolvimento +cp .env.example .env.local # preencha com credenciais do Supabase npm run dev - -# Build para produção -npm run build - -# Executar produção -npm start -``` - -Acesse [http://localhost:3000](http://localhost:3000) - -## 📚 Estrutura de Cálculos - -### Juros Compostos - -Fórmula implementada: -``` -A = P(1 + r)^n + PMT × [(1 + r)^n - 1] / r ``` -Onde: -- A = Montante final -- P = Capital inicial -- r = Taxa de juros por período -- n = Número de períodos -- PMT = Aporte mensal +### Setup do Supabase +1. Crie projeto em https://supabase.com +2. SQL Editor → rode `supabase/migrations/20251003000000_init.sql` +3. Authentication → Providers → Email → confirme se quer/desativar confirmação +4. Settings → API → copie `URL` e `anon public key` para `.env.local` -### FIRE Number +## 📁 Estrutura -Baseado na Trinity Study e regra dos 4%: ``` -FIRE Number = Despesas Anuais / Taxa de Retirada Segura +src/ +├── core/ +│ ├── domain/ # Tipos e constantes +│ │ ├── portfolio.types.ts +│ │ ├── asset-classes.ts +│ │ └── portfolio-constants.ts +│ ├── services/ # Lógica de cálculo (pura) +│ │ ├── portfolio.service.ts +│ │ ├── fixedIncome.service.ts +│ │ ├── projection.service.ts +│ │ ├── allocation.service.ts +│ │ ├── dividends.service.ts +│ │ ├── priceFetcher.service.ts +│ │ ├── currency.service.ts +│ │ └── portfolio-aggregator.service.ts +│ ├── data/ # Acesso ao banco (server-only) +│ └── utils/ +├── components/ +│ ├── auth/ # AuthForm, UserMenu +│ ├── portfolio/ # AssetForm, TransactionForm, AllocationView, etc. +│ └── Dashboard/ # PortfolioTeaser +├── hooks/ # useAuth +└── lib/supabase/ # client, server, middleware, types + +supabase/ +└── migrations/ # SQL versionado + +app/ +├── (auth)/ # /login, /signup +├── portfolio/ # carteira + sub-rotas +├── assets/ # CRUD de ativos +└── transactions/ # CRUD de transações ``` -Considera: -- Inflação (IPCA médio histórico) -- Taxa real de retorno (descontada inflação) -- Aportes mensais recorrentes -- Simulação ano a ano até a meta - -## 🔒 Garantia de Precisão +## 🧪 Testes -```typescript -// ❌ NUNCA faça isso (ponto flutuante impreciso) -const result = 0.1 + 0.2; // 0.30000000000000004 +116 testes com Vitest cobrindo serviços de cálculo, hooks e componentes. -// ✅ SEMPRE use Decimal -import Decimal from 'decimal.js'; -const result = new Decimal(0.1).plus(0.2); // 0.3 +```bash +npm test +npm run test:coverage ``` -Todos os cálculos monetários usam `decimal.js` para garantir precisão de até 20 casas decimais. - -## 🎨 Componentização - -Os componentes seguem a estrutura do shadcn/ui com customizações: +## 📐 Regras de Negócio -- **Card**: Container base para seções -- **Input**: Campos de formulário com validação -- **Button**: Botões com variantes de estilo -- **Label**: Labels acessíveis para formulários -- **Charts**: Gráficos construídos com Recharts - -## 🧪 Próximos Passos - -- [ ] Adicionar testes unitários (Vitest) -- [ ] Implementar conversor de taxas -- [ ] Adicionar calculadora de poder de compra -- [ ] Implementar comparador de investimentos -- [ ] Adicionar suporte a múltiplas moedas +- **Preço médio** = Σ(qty × preço) / Σ(qty) − vendas parciais reduzem qty e custo +- **P&L realizado** = Σ(venda − preço_médio_na_data) +- **P&L não realizado** = (preço_atual − preço_médio) × quantidade +- **Yield on cost** = Σ(dividendos) / custo_total × 100 +- **Renda Fixa**: CDI% / IPCA+ / Prefixado com capitalização anual +- **Multi-moeda**: agregação sempre em BRL usando `USDBRL` cacheado ## 📄 Licença -MIT - ---- +MIT \ No newline at end of file diff --git a/app/(auth)/actions.ts b/app/(auth)/actions.ts new file mode 100644 index 0000000..4724e4c --- /dev/null +++ b/app/(auth)/actions.ts @@ -0,0 +1,66 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; +import { createClient } from "@/lib/supabase/server"; + +export async function loginAction(formData: FormData) { + const email = String(formData.get("email") ?? "").trim(); + const password = String(formData.get("password") ?? ""); + + if (!email || !password) { + return { error: "Informe e-mail e senha." }; + } + + const supabase = await createClient(); + const { error } = await supabase.auth.signInWithPassword({ email, password }); + + if (error) { + if (error.message.toLowerCase().includes("email not confirmed")) { + return { error: "Confirme seu e-mail antes de entrar (verifique a caixa de entrada ou desative a confirmação nas configs do Supabase)." }; + } + if (error.message.toLowerCase().includes("invalid login")) { + return { error: "E-mail ou senha inválidos." }; + } + return { error: error.message }; + } + + revalidatePath("/", "layout"); + redirect("/portfolio"); +} + +export async function signupAction(formData: FormData) { + const email = String(formData.get("email") ?? "").trim(); + const password = String(formData.get("password") ?? ""); + const displayName = String(formData.get("displayName") ?? "").trim(); + + if (!email || !password) { + return { error: "Informe e-mail e senha." }; + } + if (password.length < 6) { + return { error: "A senha deve ter pelo menos 6 caracteres." }; + } + + const supabase = await createClient(); + const { error } = await supabase.auth.signUp({ + email, + password, + options: { + data: { display_name: displayName || email.split("@")[0] }, + }, + }); + + if (error) { + return { error: error.message }; + } + + revalidatePath("/", "layout"); + redirect("/portfolio"); +} + +export async function logoutAction() { + const supabase = await createClient(); + await supabase.auth.signOut(); + revalidatePath("/", "layout"); + redirect("/login"); +} \ No newline at end of file diff --git a/app/(auth)/layout.tsx b/app/(auth)/layout.tsx new file mode 100644 index 0000000..25041be --- /dev/null +++ b/app/(auth)/layout.tsx @@ -0,0 +1,19 @@ +import { redirect } from "next/navigation"; +import { createClient } from "@/lib/supabase/server"; + +export default async function AuthLayout({ + children, +}: { + children: React.ReactNode; +}) { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + + if (user) { + redirect("/portfolio"); + } + + return <>{children}; +} \ No newline at end of file diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx new file mode 100644 index 0000000..ff97ace --- /dev/null +++ b/app/(auth)/login/page.tsx @@ -0,0 +1,9 @@ +import { AuthForm } from "@/components/auth/AuthForm"; + +export default function LoginPage() { + return ( +
+ +
+ ); +} \ No newline at end of file diff --git a/app/(auth)/signup/page.tsx b/app/(auth)/signup/page.tsx new file mode 100644 index 0000000..f07b263 --- /dev/null +++ b/app/(auth)/signup/page.tsx @@ -0,0 +1,9 @@ +import { AuthForm } from "@/components/auth/AuthForm"; + +export default function SignupPage() { + return ( +
+ +
+ ); +} \ No newline at end of file diff --git a/app/api/prices/refresh/route.ts b/app/api/prices/refresh/route.ts new file mode 100644 index 0000000..0c099fe --- /dev/null +++ b/app/api/prices/refresh/route.ts @@ -0,0 +1,76 @@ +import { NextResponse } from "next/server"; +import { createClient } from "@/lib/supabase/server"; +import { + fetchPrice, + saveCachedPrice, + fetchUsdBrl, +} from "@/core/services/priceFetcher.service"; +import type { AssetType, Currency } from "@/lib/supabase/types"; + +export async function POST() { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) { + return NextResponse.json({ error: "Não autenticado" }, { status: 401 }); + } + + const { data: assets } = await supabase + .from("assets") + .select("ticker, type, currency") + .eq("user_id", user.id); + + if (!assets || assets.length === 0) { + return NextResponse.json({ updated: 0, failed: 0, results: [] }); + } + + const results: Array<{ + symbol: string; + status: "ok" | "failed"; + price?: number; + error?: string; + }> = []; + + let updated = 0; + let failed = 0; + + for (const a of assets as Array<{ ticker: string; type: AssetType; currency: Currency }>) { + if (a.type === "FIXED_INCOME" || a.type === "CASH") continue; + try { + const r = await fetchPrice(a.type, a.ticker, a.currency); + if (r) { + await saveCachedPrice(r); + results.push({ symbol: a.ticker, status: "ok", price: r.price }); + updated++; + } else { + results.push({ symbol: a.ticker, status: "failed", error: "no_data" }); + failed++; + } + } catch (e) { + results.push({ + symbol: a.ticker, + status: "failed", + error: e instanceof Error ? e.message : "unknown", + }); + failed++; + } + } + + // Atualizar USD/BRL também + const usdBrl = await fetchUsdBrl(); + if (usdBrl) { + await supabase.from("price_cache").upsert( + { + symbol: "USDBRL", + currency: "BRL", + price: usdBrl, + source: "awesomeapi", + updated_at: new Date().toISOString(), + }, + { onConflict: "symbol,currency" }, + ); + } + + return NextResponse.json({ updated, failed, results, usdBrl }); +} \ No newline at end of file diff --git a/app/assets/[id]/page.tsx b/app/assets/[id]/page.tsx new file mode 100644 index 0000000..f2075c7 --- /dev/null +++ b/app/assets/[id]/page.tsx @@ -0,0 +1,104 @@ +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { ArrowLeft, Trash2 } from "lucide-react"; + +import { createClient } from "@/lib/supabase/server"; +import { AssetForm } from "@/components/portfolio/AssetForm"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { ASSET_CLASS_META } from "@/core/domain/asset-classes"; +import { deleteAssetAction } from "@/app/assets/actions"; + +interface PageProps { + params: Promise<{ id: string }>; +} + +export default async function AssetDetailPage({ params }: PageProps) { + const { id } = await params; + const supabase = await createClient(); + const { data: asset, error } = await supabase + .from("assets") + .select("*") + .eq("id", id) + .single(); + + if (error || !asset) notFound(); + + let fixedIncome: { + kind: string; + indexer: string; + rate: number; + maturity_date: string | null; + issuer: string | null; + } | null = null; + if (asset.type === "FIXED_INCOME") { + const { data: fi } = await supabase + .from("fixed_income_details") + .select("*") + .eq("asset_id", id) + .single(); + fixedIncome = fi; + } + + const meta = ASSET_CLASS_META[asset.type as keyof typeof ASSET_CLASS_META]; + + return ( + <> + + + Voltar + +
+
+
+

{asset.ticker}

+ + {meta?.label} + +
+

{asset.name}

+
+
+ + +
+
+ +
+

Editar informações

+ +
+ + ); +} \ No newline at end of file diff --git a/app/assets/actions.ts b/app/assets/actions.ts new file mode 100644 index 0000000..b2b7b34 --- /dev/null +++ b/app/assets/actions.ts @@ -0,0 +1,208 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; +import { z } from "zod"; +import { createClient } from "@/lib/supabase/server"; +import type { AssetType, Currency, FixedIncomeIndexer, FixedIncomeKind } from "@/lib/supabase/types"; + +const assetBaseSchema = z.object({ + ticker: z.string().trim().min(1, "Ticker obrigatório").max(20), + name: z.string().trim().min(1, "Nome obrigatório").max(100), + type: z.enum(["STOCK", "ETF", "FII", "CRYPTO", "FIXED_INCOME", "CASH"]), + currency: z.enum(["BRL", "USD"]), + exchange: z.string().trim().max(50).optional().nullable(), + sector: z.string().trim().max(50).optional().nullable(), +}); + +const fixedIncomeSchema = z + .object({ + kind: z.enum(["CDB", "LCI", "LCA", "TESOURO", "DEBENTURE", "CRI", "CRA", "LC", "OTHER"]), + indexer: z.enum(["PRE", "CDI", "IPCA", "SELIC", "OTHER"]), + rate: z.coerce.number().min(0, "Taxa deve ser ≥ 0"), + maturityDate: z.string().optional().nullable(), + issuer: z.string().trim().max(100).optional().nullable(), + }) + .optional(); + +const createAssetSchema = assetBaseSchema.extend({ + fixedIncome: fixedIncomeSchema, +}); + +const updateAssetSchema = assetBaseSchema.extend({ + id: z.string().uuid(), + fixedIncome: fixedIncomeSchema, +}); + +export type AssetActionResult = { + error?: string; + fieldErrors?: Record; +}; + +function parseForm(formData: FormData) { + const obj: Record = {}; + for (const [k, v] of formData.entries()) { + if (k === "fixedIncome" || k.startsWith("fixedIncome.")) continue; + obj[k] = typeof v === "string" ? v : v.name; + } + // Fixed income aninhado + const fi: Record = {}; + for (const [k, v] of formData.entries()) { + if (k.startsWith("fixedIncome.")) { + const key = k.replace("fixedIncome.", ""); + fi[key] = typeof v === "string" ? v : v.name; + } + } + if (Object.keys(fi).length > 0) obj.fixedIncome = fi; + return obj; +} + +export async function createAssetAction( + _prev: AssetActionResult, + formData: FormData, +): Promise { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) return { error: "Não autenticado." }; + + const parsed = createAssetSchema.safeParse(parseForm(formData)); + if (!parsed.success) { + const fieldErrors: Record = {}; + for (const issue of parsed.error.issues) { + fieldErrors[issue.path.join(".")] = issue.message; + } + return { fieldErrors }; + } + + const data = parsed.data; + const { data: asset, error } = await supabase + .from("assets") + .insert({ + user_id: user.id, + ticker: data.ticker.toUpperCase(), + name: data.name, + type: data.type, + currency: data.currency, + exchange: data.exchange || null, + sector: data.sector || null, + }) + .select() + .single(); + + if (error || !asset) { + if (error?.code === "23505") { + return { error: "Você já tem um ativo com este ticker e tipo." }; + } + return { error: error?.message ?? "Erro ao criar ativo." }; + } + + // Renda fixa: inserir detalhes + if (data.type === "FIXED_INCOME" && data.fixedIncome) { + const { error: fiError } = await supabase.from("fixed_income_details").insert({ + asset_id: asset.id, + kind: data.fixedIncome.kind as FixedIncomeKind, + indexer: data.fixedIncome.indexer as FixedIncomeIndexer, + rate: data.fixedIncome.rate, + maturity_date: data.fixedIncome.maturityDate || null, + issuer: data.fixedIncome.issuer || null, + }); + if (fiError) { + // rollback + await supabase.from("assets").delete().eq("id", asset.id); + return { error: `Erro ao salvar detalhes de renda fixa: ${fiError.message}` }; + } + } + + revalidatePath("/assets"); + revalidatePath("/portfolio"); + redirect("/assets"); +} + +export async function updateAssetAction( + _prev: AssetActionResult, + formData: FormData, +): Promise { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) return { error: "Não autenticado." }; + + const parsed = updateAssetSchema.safeParse(parseForm(formData)); + if (!parsed.success) { + const fieldErrors: Record = {}; + for (const issue of parsed.error.issues) { + fieldErrors[issue.path.join(".")] = issue.message; + } + return { fieldErrors }; + } + + const data = parsed.data; + const { error } = await supabase + .from("assets") + .update({ + ticker: data.ticker.toUpperCase(), + name: data.name, + type: data.type, + currency: data.currency, + exchange: data.exchange || null, + sector: data.sector || null, + }) + .eq("id", data.id) + .eq("user_id", user.id); + + if (error) return { error: error.message }; + + if (data.type === "FIXED_INCOME" && data.fixedIncome) { + await supabase.from("fixed_income_details").upsert({ + asset_id: data.id, + kind: data.fixedIncome.kind as FixedIncomeKind, + indexer: data.fixedIncome.indexer as FixedIncomeIndexer, + rate: data.fixedIncome.rate, + maturity_date: data.fixedIncome.maturityDate || null, + issuer: data.fixedIncome.issuer || null, + }); + } else if (data.type !== "FIXED_INCOME") { + await supabase.from("fixed_income_details").delete().eq("asset_id", data.id); + } + + revalidatePath("/assets"); + revalidatePath(`/assets/${data.id}`); + revalidatePath("/portfolio"); + redirect(`/assets/${data.id}`); +} + +export async function deleteAssetAction(formData: FormData) { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) return; + + const id = String(formData.get("id") ?? ""); + if (!id) return; + + await supabase.from("assets").delete().eq("id", id).eq("user_id", user.id); + revalidatePath("/assets"); + revalidatePath("/portfolio"); + redirect("/assets"); +} + +export type AssetFormValues = { + id?: string; + ticker: string; + name: string; + type: AssetType; + currency: Currency; + exchange: string | null; + sector: string | null; + fixedIncome?: { + kind: FixedIncomeKind; + indexer: FixedIncomeIndexer; + rate: number; + maturityDate: string | null; + issuer: string | null; + }; +}; \ No newline at end of file diff --git a/app/assets/layout.tsx b/app/assets/layout.tsx new file mode 100644 index 0000000..fe0e7f5 --- /dev/null +++ b/app/assets/layout.tsx @@ -0,0 +1,10 @@ +import type { ReactNode } from "react"; +import { PortfolioShell } from "@/components/portfolio/PortfolioShell"; + +export default function AssetsLayout({ + children, +}: { + children: ReactNode; +}) { + return {children}; +} \ No newline at end of file diff --git a/app/assets/new/page.tsx b/app/assets/new/page.tsx new file mode 100644 index 0000000..f641442 --- /dev/null +++ b/app/assets/new/page.tsx @@ -0,0 +1,27 @@ +import Link from "next/link"; +import { ArrowLeft } from "lucide-react"; + +import { AssetForm } from "@/components/portfolio/AssetForm"; + +export default function NewAssetPage() { + return ( + <> + + + Voltar + +
+

Cadastrar ativo

+

+ Adicione um novo ativo à sua carteira. +

+
+
+ +
+ + ); +} \ No newline at end of file diff --git a/app/assets/page.tsx b/app/assets/page.tsx new file mode 100644 index 0000000..ef4716d --- /dev/null +++ b/app/assets/page.tsx @@ -0,0 +1,98 @@ +import Link from "next/link"; +import { Coins, Plus } from "lucide-react"; + +import { createClient } from "@/lib/supabase/server"; +import { Card, CardContent } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { ASSET_CLASS_META } from "@/core/domain/asset-classes"; +import type { AssetType } from "@/lib/supabase/types"; + +interface AssetRow { + id: string; + ticker: string; + name: string; + type: AssetType; + currency: string; + exchange: string | null; +} + +export default async function AssetsPage() { + const supabase = await createClient(); + const { data } = await supabase + .from("assets") + .select("id, ticker, name, type, currency, exchange") + .order("created_at", { ascending: false }); + + const assets: AssetRow[] = (data ?? []) as AssetRow[]; + + return ( + <> +
+
+

Meus Ativos

+

+ {assets.length} {assets.length === 1 ? "ativo cadastrado" : "ativos cadastrados"} +

+
+ +
+ + {assets.length === 0 ? ( + + +
+ +
+
+

Nenhum ativo cadastrado

+

+ Cadastre ações, FIIs, ETFs, cripto, renda fixa e contas. +

+
+ +
+
+ ) : ( +
+ {assets.map((asset) => { + const meta = ASSET_CLASS_META[asset.type]; + return ( + +
+
+

{asset.ticker}

+

+ {asset.name} +

+
+ + {meta.label} + +
+
+ {asset.exchange || "—"} + {asset.currency} +
+ + ); + })} +
+ )} + + ); +} \ No newline at end of file diff --git a/app/globals.css b/app/globals.css index 157625b..c07c54d 100644 --- a/app/globals.css +++ b/app/globals.css @@ -145,10 +145,56 @@ /* Card */ --color-card: hsl(var(--card)); + --color-card-foreground: hsl(var(--card-foreground)); + + /* Popover */ + --color-popover: hsl(var(--popover)); + --color-popover-foreground: hsl(var(--popover-foreground)); + + /* Primary */ + --color-primary: hsl(var(--primary)); + --color-primary-foreground: hsl(var(--primary-foreground)); + + /* Secondary */ + --color-secondary: hsl(var(--secondary)); + --color-secondary-foreground: hsl(var(--secondary-foreground)); + + /* Muted */ + --color-muted: hsl(var(--muted)); + --color-muted-foreground: hsl(var(--muted-foreground)); + + /* Accent */ + --color-accent: hsl(var(--accent)); + --color-accent-foreground: hsl(var(--accent-foreground)); + + /* Destructive */ + --color-destructive: hsl(var(--destructive)); + --color-destructive-foreground: hsl(var(--destructive-foreground)); + + /* Success */ --color-success: hsl(var(--success)); --color-success-foreground: hsl(var(--success-foreground)); + + /* Warning */ --color-warning: hsl(var(--warning)); --color-warning-foreground: hsl(var(--warning-foreground)); + + /* Borders & Inputs */ + --color-border: hsl(var(--border)); + --color-input: hsl(var(--input)); + --color-ring: hsl(var(--ring)); + + /* Sidebar */ + --color-sidebar: hsl(var(--sidebar)); + --color-sidebar-foreground: hsl(var(--sidebar-foreground)); + --color-sidebar-primary: hsl(var(--sidebar-primary)); + --color-sidebar-primary-foreground: hsl(var(--sidebar-primary-foreground)); + --color-sidebar-accent: hsl(var(--sidebar-accent)); + --color-sidebar-accent-foreground: hsl(var(--sidebar-accent-foreground)); + --color-sidebar-border: hsl(var(--sidebar-border)); + --color-sidebar-ring: hsl(var(--sidebar-ring)); + + /* Radius */ --radius-sm: calc(var(--radius) - 4px); --radius-md: calc(var(--radius) - 2px); --radius-lg: var(--radius); diff --git a/app/page.tsx b/app/page.tsx index d02b8a3..08c935c 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,5 +1,11 @@ import { Dashboard } from "@/components/Dashboard"; +import { PortfolioTeaser } from "@/components/Dashboard/PortfolioTeaser"; export default function Home() { - return ; -} + return ( + <> + + + + ); +} \ No newline at end of file diff --git a/app/portfolio/allocation/page.tsx b/app/portfolio/allocation/page.tsx new file mode 100644 index 0000000..ea59e51 --- /dev/null +++ b/app/portfolio/allocation/page.tsx @@ -0,0 +1,73 @@ +import { createClient } from "@/lib/supabase/server"; +import { + fetchAssetsWithPrices, + fetchTransactions, +} from "@/core/data/portfolio-data"; +import { + calculatePortfolio, +} from "@/core/services/portfolio.service"; +import { calculateAllocation } from "@/core/services/allocation.service"; +import { AllocationView } from "@/components/portfolio/AllocationView"; +import { RefreshPricesButton } from "@/components/portfolio/RefreshPricesButton"; +import type { Asset, Transaction } from "@/core/domain/portfolio.types"; +import type { AssetType, Currency } from "@/lib/supabase/types"; + +export default async function AllocationPage() { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) return null; + + const [assetsWithPrices, transactions] = await Promise.all([ + fetchAssetsWithPrices(user.id), + fetchTransactions(user.id), + ]); + + const assets: Asset[] = assetsWithPrices.map((a) => ({ + id: a.id, + ticker: a.ticker, + name: a.name, + type: a.type as AssetType, + currency: a.currency as Currency, + exchange: a.exchange, + sector: a.sector, + metadata: (a.metadata as Record) ?? {}, + })); + + const txs: Transaction[] = transactions.map((t) => ({ + id: t.id, + assetId: t.asset_id, + type: t.type, + quantity: Number(t.quantity), + unitPrice: Number(t.unit_price), + total: Number(t.total), + fees: Number(t.fees), + currency: t.currency as Currency, + date: t.date, + notes: t.notes, + })); + + const currentPrices: Record = {}; + for (const a of assetsWithPrices) { + currentPrices[a.id] = a.price ? Number(a.price.price) : null; + } + + const positions = calculatePortfolio(assets, txs, currentPrices); + const allocation = calculateAllocation(positions); + + return ( + <> +
+
+

Alocação

+

+ Diversificação da carteira por classe de ativo. +

+
+ +
+ + + ); +} \ No newline at end of file diff --git a/app/portfolio/dividends/page.tsx b/app/portfolio/dividends/page.tsx new file mode 100644 index 0000000..33fbe30 --- /dev/null +++ b/app/portfolio/dividends/page.tsx @@ -0,0 +1,193 @@ +import { TrendingUp, DollarSign, Calendar } from "lucide-react"; + +import { createClient } from "@/lib/supabase/server"; +import { summarizeDividends } from "@/core/services/dividends.service"; +import { calculatePortfolio } from "@/core/services/portfolio.service"; +import { Card, CardContent } from "@/components/ui/card"; +import { DividendsChart } from "@/components/portfolio/DividendsChart"; +import { + fetchAssetsWithPrices, + fetchTransactions, +} from "@/core/data/portfolio-data"; +import type { Asset, Transaction } from "@/core/domain/portfolio.types"; +import type { AssetType, Currency } from "@/lib/supabase/types"; + +function formatBRL(value: number) { + return new Intl.NumberFormat("pt-BR", { + style: "currency", + currency: "BRL", + minimumFractionDigits: 2, + }).format(value); +} + +export default async function DividendsPage() { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) return null; + + const [assets, transactions] = await Promise.all([ + fetchAssetsWithPrices(user.id), + fetchTransactions(user.id), + ]); + + const typedAssets: Asset[] = assets.map((a) => ({ + id: a.id, + ticker: a.ticker, + name: a.name, + type: a.type as AssetType, + currency: a.currency as Currency, + exchange: a.exchange, + sector: a.sector, + metadata: (a.metadata as Record) ?? {}, + })); + + const typedTxs: Transaction[] = transactions.map((t) => ({ + id: t.id, + assetId: t.asset_id, + type: t.type, + quantity: Number(t.quantity), + unitPrice: Number(t.unit_price), + total: Number(t.total), + fees: Number(t.fees), + currency: t.currency as Currency, + date: t.date, + notes: t.notes, + })); + + const prices: Record = {}; + for (const a of assets) prices[a.id] = a.price ? Number(a.price.price) : null; + const positions = calculatePortfolio(typedAssets, typedTxs, prices); + + const assetCosts: Record = {}; + for (const p of positions) { + assetCosts[p.asset.id] = p.totalCost.toNumber(); + } + + const summary = summarizeDividends(typedAssets, typedTxs, assetCosts); + + const chartData = summary.byMonth.map((m) => ({ + month: m.label, + total: m.total.toNumber(), + })); + + const hasDividends = summary.totalReceived.toNumber() > 0; + + return ( + <> +
+

Proventos

+

+ Dividendos, JCP e rendimentos recebidos. +

+
+ + {!hasDividends ? ( + + +
+ +
+
+

Nenhum provento registrado

+

+ Registre dividendos ou rendimentos para acompanhar seu yield on cost. +

+
+
+
+ ) : ( +
+
+ + +
+ +
+
+

+ Total recebido +

+

+ {formatBRL(summary.totalReceived.toNumber())} +

+
+
+
+ + +
+ +
+
+

+ Últimos 12 meses +

+

+ {formatBRL(summary.last12Months.toNumber())} +

+
+
+
+ + +
+ +
+
+

+ Ativos pagantes +

+

{summary.byAsset.length}

+
+
+
+
+ + {chartData.length > 0 && ( + + +

Proventos por mês

+ +
+
+ )} + + + +

Por ativo

+
+ + + + + + + + + + {summary.byAsset.map((a) => ( + + + + + + ))} + +
AtivoTotal recebidoYield on cost
{a.ticker} + {formatBRL(a.total.toNumber())} + + {a.yieldOnCost.toNumber().toFixed(2)}% +
+
+
+
+
+ )} + + ); +} \ No newline at end of file diff --git a/app/portfolio/holdings/page.tsx b/app/portfolio/holdings/page.tsx new file mode 100644 index 0000000..3080dba --- /dev/null +++ b/app/portfolio/holdings/page.tsx @@ -0,0 +1,302 @@ +import Link from "next/link"; +import { Coins } from "lucide-react"; + +import { createClient } from "@/lib/supabase/server"; +import { + fetchAssetsWithPrices, + fetchTransactions, + fetchFixedIncomeDetails, +} from "@/core/data/portfolio-data"; +import { + calculatePortfolio, + summarizePortfolio, +} from "@/core/services/portfolio.service"; +import { ASSET_CLASS_META } from "@/core/domain/asset-classes"; +import { Card, CardContent } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { RefreshPricesButton } from "@/components/portfolio/RefreshPricesButton"; +import { toBRL } from "@/core/services/currency.service"; +import type { Asset, Transaction } from "@/core/domain/portfolio.types"; +import type { AssetType, Currency } from "@/lib/supabase/types"; + +function formatCurrency(value: number, currency: string) { + return new Intl.NumberFormat("pt-BR", { + style: "currency", + currency, + minimumFractionDigits: 2, + }).format(value); +} + +function formatPercent(value: number) { + const sign = value > 0 ? "+" : ""; + return `${sign}${value.toFixed(2)}%`; +} + +export default async function HoldingsPage() { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) return null; + + const [assetsWithPrices, transactions, fiDetails] = await Promise.all([ + fetchAssetsWithPrices(user.id), + fetchTransactions(user.id), + fetchAssetsWithPrices(user.id).then((arr) => + fetchFixedIncomeDetails(arr.filter((a) => a.type === "FIXED_INCOME").map((a) => a.id)), + ), + ]); + + const assets: Asset[] = assetsWithPrices.map((a) => ({ + id: a.id, + ticker: a.ticker, + name: a.name, + type: a.type as AssetType, + currency: a.currency as Currency, + exchange: a.exchange, + sector: a.sector, + metadata: (a.metadata as Record) ?? {}, + })); + + const txs: Transaction[] = transactions.map((t) => ({ + id: t.id, + assetId: t.asset_id, + type: t.type, + quantity: Number(t.quantity), + unitPrice: Number(t.unit_price), + total: Number(t.total), + fees: Number(t.fees), + currency: t.currency as Currency, + date: t.date, + notes: t.notes, + })); + + const currentPrices: Record = {}; + for (const a of assetsWithPrices) { + currentPrices[a.id] = a.price ? Number(a.price.price) : null; + } + + const positions = calculatePortfolio(assets, txs, currentPrices); + const summary = summarizePortfolio(positions); + + // Calcular totais em BRL para agregação + let totalValueBRL = 0; + let totalCostBRL = 0; + let totalUnrealizedBRL = 0; + let totalRealizedBRL = 0; + let totalDividendsBRL = 0; + for (const p of positions) { + const c = p.asset.currency; + totalCostBRL += (await toBRL(p.totalCost, c)).toNumber(); + totalRealizedBRL += (await toBRL(p.realizedPnL, c)).toNumber(); + totalDividendsBRL += (await toBRL(p.dividendsReceived, c)).toNumber(); + if (p.currentValue !== null) { + totalValueBRL += (await toBRL(p.currentValue, c)).toNumber(); + totalUnrealizedBRL += (await toBRL(p.unrealizedPnL ?? new (await import("decimal.js")).default(0), c)).toNumber(); + } + } + const totalReturnBRL = totalUnrealizedBRL + totalRealizedBRL + totalDividendsBRL; + const totalReturnPercent = totalCostBRL > 0 ? (totalReturnBRL / totalCostBRL) * 100 : 0; + + const fiMap = new Map(fiDetails.map((f) => [f.asset_id, f])); + const sorted = [...positions].sort((a, b) => { + const av = a.currentValue?.toNumber() ?? 0; + const bv = b.currentValue?.toNumber() ?? 0; + return bv - av; + }); + + const hasAnyPosition = sorted.some((p) => !p.quantity.isZero()); + + return ( + <> +
+
+

Posições

+

+ {summary.positionCount} {summary.positionCount === 1 ? "ativo" : "ativos"} +

+
+ +
+ +
+ + +

+ Patrimônio atual +

+

+ {totalValueBRL > 0 ? formatCurrency(totalValueBRL, "BRL") : "—"} +

+

em BRL

+
+
+ + +

+ Custo total (BRL) +

+

+ {formatCurrency(totalCostBRL, "BRL")} +

+
+
+ + +

+ Retorno total +

+

= 0 ? "text-emerald-600" : "text-red-600" + }`} + > + {totalCostBRL > 0 ? formatCurrency(totalReturnBRL, "BRL") : "—"} + {totalCostBRL > 0 && ( + + ({formatPercent(totalReturnPercent)}) + + )} +

+

+ inclui dividendos +

+
+
+
+ + {!hasAnyPosition ? ( + + +
+ +
+
+

Nenhuma posição aberta

+

+ Cadastre ativos e registre transações para começar. +

+
+
+ + +
+
+
+ ) : ( + + +
+ + + + + + + + + + + + + + {sorted.map((p) => { + const meta = ASSET_CLASS_META[p.asset.type]; + const fi = fiMap.get(p.asset.id); + const hasPosition = !p.quantity.isZero(); + const isPositive = + p.unrealizedPnL && p.unrealizedPnL.greaterThanOrEqualTo(0); + const cur = p.asset.currency; + return ( + + + + + + + + + + ); + })} + +
AtivoQtdPreço médioCusto totalPreço atualValor atualP&L
+ + {p.asset.ticker} + + {meta.label} + + + {fi && ( +

+ {fi.kind} • {fi.indexer} {fi.rate} + {fi.indexer === "PRE" ? "% a.a." : ""} +

+ )} +
+ {hasPosition + ? p.quantity.toNumber().toLocaleString("pt-BR", { + maximumFractionDigits: 8, + }) + : "—"} + + {hasPosition + ? formatCurrency(p.avgPrice.toNumber(), cur) + : "—"} + + {p.totalCost.toNumber() > 0 + ? formatCurrency(p.totalCost.toNumber(), cur) + : "—"} + + {p.currentPrice + ? formatCurrency(p.currentPrice.toNumber(), cur) + : "—"} + + {p.currentValue ? formatCurrency(p.currentValue.toNumber(), cur) : "—"} + + {p.unrealizedPnL ? ( +
+
+ {formatCurrency(p.unrealizedPnL.toNumber(), cur)} +
+ {p.totalReturnPercent && p.totalCost.toNumber() > 0 && p.totalReturn && ( +
+ {formatPercent(p.totalReturnPercent.toNumber())} +
+ )} +
+ ) : ( + "—" + )} +
+
+
+
+ )} + + ); +} \ No newline at end of file diff --git a/app/portfolio/layout.tsx b/app/portfolio/layout.tsx new file mode 100644 index 0000000..f07ff74 --- /dev/null +++ b/app/portfolio/layout.tsx @@ -0,0 +1,10 @@ +import type { ReactNode } from "react"; +import { PortfolioShell } from "@/components/portfolio/PortfolioShell"; + +export default function PortfolioLayout({ + children, +}: { + children: ReactNode; +}) { + return {children}; +} \ No newline at end of file diff --git a/app/portfolio/page.tsx b/app/portfolio/page.tsx new file mode 100644 index 0000000..89ba7d7 --- /dev/null +++ b/app/portfolio/page.tsx @@ -0,0 +1,109 @@ +import Link from "next/link"; +import { + Coins, + TrendingUp, + PieChart, + LineChart, + Receipt, + ArrowRight, +} from "lucide-react"; + +import { createClient } from "@/lib/supabase/server"; +import { Card, CardContent } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; + +const sections = [ + { + title: "Posições", + description: "Lista de ativos, preço médio, P&L e rentabilidade por posição.", + icon: Coins, + href: "/portfolio/holdings", + }, + { + title: "Transações", + description: "Histórico completo de compras, vendas, dividendos e aportes.", + icon: Receipt, + href: "/transactions", + }, + { + title: "Alocação", + description: "Diversificação por classe de ativo e concentração da carteira.", + icon: PieChart, + href: "/portfolio/allocation", + }, + { + title: "Projeção", + description: "Simule a evolução do patrimônio com aportes recorrentes.", + icon: LineChart, + href: "/portfolio/projection", + }, +]; + +export default async function PortfolioPage() { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + + const displayName = + (user?.user_metadata.display_name as string | undefined) ?? + user?.email?.split("@")[0] ?? + "investidor"; + + return ( + <> +
+

+ Olá, {displayName} +

+

+ Acompanhe e projete sua carteira de investimentos. +

+
+ + + + +
+

Sua carteira está vazia

+

+ Comece cadastrando seus primeiros ativos e transações. +

+
+
+ + +
+
+
+ +

Acessar

+
+ {sections.map((s) => ( + +
+
+ +
+
+

{s.title}

+

{s.description}

+
+
+ + ))} +
+ + ); +} \ No newline at end of file diff --git a/app/portfolio/projection/page.tsx b/app/portfolio/projection/page.tsx new file mode 100644 index 0000000..1a9739b --- /dev/null +++ b/app/portfolio/projection/page.tsx @@ -0,0 +1,46 @@ +import { createClient } from "@/lib/supabase/server"; +import { ProjectionPanel } from "@/components/portfolio/ProjectionPanel"; + +export default async function ProjectionPage() { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + + // Defaults baseados nas taxas configuradas pelo usuário (se houver) + const defaults = { + initialAmount: 0, + monthlyContribution: 1000, + annualRate: 10, + years: 20, + ipca: 4.5, + }; + + if (user) { + const { data: profile } = await supabase + .from("profiles") + .select("expected_rates, ipca_annual") + .eq("id", user.id) + .single(); + if (profile) { + // taxa média ponderada simples (igual peso por classe) + const rates = profile.expected_rates as unknown as Record; + const avg = + Object.values(rates).reduce((s, v) => s + v, 0) / Object.values(rates).length; + defaults.annualRate = Number((avg * 100).toFixed(1)); + defaults.ipca = Number(profile.ipca_annual); + } + } + + return ( + <> +
+

Projeção

+

+ Simule a evolução do seu patrimônio ao longo dos anos. +

+
+ + + ); +} \ No newline at end of file diff --git a/app/portfolio/settings/actions.ts b/app/portfolio/settings/actions.ts new file mode 100644 index 0000000..a46bc2a --- /dev/null +++ b/app/portfolio/settings/actions.ts @@ -0,0 +1,198 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { z } from "zod"; +import { createClient } from "@/lib/supabase/server"; + +const exportSchema = z.object({ + version: z.literal(1), + exportedAt: z.string(), + assets: z.array( + z.object({ + ticker: z.string(), + name: z.string(), + type: z.enum(["STOCK", "ETF", "FII", "CRYPTO", "FIXED_INCOME", "CASH"]), + currency: z.enum(["BRL", "USD"]), + exchange: z.string().nullable().optional(), + sector: z.string().nullable().optional(), + fixedIncome: z + .object({ + kind: z.enum([ + "CDB", + "LCI", + "LCA", + "TESOURO", + "DEBENTURE", + "CRI", + "CRA", + "LC", + "OTHER", + ]), + indexer: z.enum(["PRE", "CDI", "IPCA", "SELIC", "OTHER"]), + rate: z.number(), + maturity_date: z.string().nullable().optional(), + issuer: z.string().nullable().optional(), + }) + .optional() + .nullable(), + transactions: z.array( + z.object({ + type: z.enum([ + "BUY", + "SELL", + "DIVIDEND", + "INCOME", + "SPLIT", + "BONUS", + ]), + quantity: z.number(), + unit_price: z.number(), + fees: z.number().default(0), + currency: z.enum(["BRL", "USD"]), + date: z.string(), + notes: z.string().nullable().optional(), + }), + ), + }), + ), +}); + +export async function exportPortfolioAction(): Promise<{ + ok: boolean; + data?: unknown; + error?: string; +}> { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) return { ok: false, error: "Não autenticado." }; + + const assetsResult = await supabase + .from("assets") + .select("*") + .eq("user_id", user.id); + const userAssets = assetsResult.data ?? []; + + const [{ data: transactions }, { data: fiDetails }] = await Promise.all([ + supabase.from("transactions").select("*").eq("user_id", user.id), + supabase + .from("fixed_income_details") + .select("*") + .in( + "asset_id", + userAssets.map((a: { id: string }) => a.id), + ), + ]); + + const fiMap = new Map((fiDetails ?? []).map((f) => [f.asset_id, f])); + + const payload = { + version: 1 as const, + exportedAt: new Date().toISOString(), + assets: (userAssets ?? []).map((a: { id: string; ticker: string; name: string; type: string; currency: string; exchange: string | null; sector: string | null }) => { + const txs = (transactions ?? []).filter((t: { asset_id: string }) => t.asset_id === a.id); + return { + ticker: a.ticker, + name: a.name, + type: a.type, + currency: a.currency, + exchange: a.exchange, + sector: a.sector, + fixedIncome: a.type === "FIXED_INCOME" && fiMap.has(a.id) ? fiMap.get(a.id) : null, + transactions: txs.map((t) => ({ + type: t.type, + quantity: Number(t.quantity), + unit_price: Number(t.unit_price), + fees: Number(t.fees), + currency: t.currency, + date: t.date, + notes: t.notes, + })), + }; + }), + }; + + return { ok: true, data: payload }; +} + +export async function importPortfolioAction( + _prev: { error?: string; imported?: number } | undefined, + formData: FormData, +): Promise<{ error?: string; imported?: number }> { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) return { error: "Não autenticado." }; + + const file = formData.get("file"); + if (!(file instanceof File)) return { error: "Arquivo inválido." }; + + let raw: unknown; + try { + raw = JSON.parse(await file.text()); + } catch { + return { error: "JSON inválido." }; + } + + const parsed = exportSchema.safeParse(raw); + if (!parsed.success) { + return { error: "Formato do arquivo não reconhecido." }; + } + + let imported = 0; + for (const assetEntry of parsed.data.assets) { + const { data: asset, error: aErr } = await supabase + .from("assets") + .insert({ + user_id: user.id, + ticker: assetEntry.ticker, + name: assetEntry.name, + type: assetEntry.type, + currency: assetEntry.currency, + exchange: assetEntry.exchange ?? null, + sector: assetEntry.sector ?? null, + }) + .select() + .single(); + + if (aErr || !asset) continue; + + if (assetEntry.type === "FIXED_INCOME" && assetEntry.fixedIncome) { + await supabase.from("fixed_income_details").insert({ + asset_id: asset.id, + kind: assetEntry.fixedIncome.kind, + indexer: assetEntry.fixedIncome.indexer, + rate: assetEntry.fixedIncome.rate, + maturity_date: assetEntry.fixedIncome.maturity_date ?? null, + issuer: assetEntry.fixedIncome.issuer ?? null, + }); + } + + if (assetEntry.transactions.length > 0) { + await supabase.from("transactions").insert( + assetEntry.transactions.map((t) => ({ + user_id: user.id, + asset_id: asset.id, + type: t.type, + quantity: t.quantity, + unit_price: t.unit_price, + total: t.quantity * t.unit_price + (t.fees ?? 0), + fees: t.fees ?? 0, + currency: t.currency, + date: t.date, + notes: t.notes ?? null, + })), + ); + } + imported++; + } + + revalidatePath("/portfolio"); + revalidatePath("/portfolio/holdings"); + revalidatePath("/transactions"); + revalidatePath("/assets"); + + return { imported }; +} \ No newline at end of file diff --git a/app/portfolio/settings/page.tsx b/app/portfolio/settings/page.tsx new file mode 100644 index 0000000..6878e90 --- /dev/null +++ b/app/portfolio/settings/page.tsx @@ -0,0 +1,15 @@ +import { PortfolioSettings } from "@/components/portfolio/PortfolioSettings"; + +export default function PortfolioSettingsPage() { + return ( + <> +
+

Configurações

+

+ Backup, exportação e importação da carteira. +

+
+ + + ); +} \ No newline at end of file diff --git a/app/portfolio/simulator/page.tsx b/app/portfolio/simulator/page.tsx new file mode 100644 index 0000000..782332b --- /dev/null +++ b/app/portfolio/simulator/page.tsx @@ -0,0 +1,86 @@ +import { createClient } from "@/lib/supabase/server"; +import { Simulator } from "@/components/portfolio/Simulator"; +import type { Asset, Transaction } from "@/core/domain/portfolio.types"; +import type { AssetType, Currency } from "@/lib/supabase/types"; +import { + fetchAssetsWithPrices, + fetchTransactions, +} from "@/core/data/portfolio-data"; + +export default async function SimulatorPage() { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + + const defaults = { + initialAmount: 0, + monthlyContribution: 1000, + annualRate: 10, + years: 20, + }; + + if (user) { + // Patrimônio inicial = valor atual da carteira + const [assets, txs] = await Promise.all([ + fetchAssetsWithPrices(user.id), + fetchTransactions(user.id), + ]); + + const typedAssets: Asset[] = assets.map((a) => ({ + id: a.id, + ticker: a.ticker, + name: a.name, + type: a.type as AssetType, + currency: a.currency as Currency, + exchange: a.exchange, + sector: a.sector, + metadata: (a.metadata as Record) ?? {}, + })); + const typedTxs: Transaction[] = txs.map((t) => ({ + id: t.id, + assetId: t.asset_id, + type: t.type, + quantity: Number(t.quantity), + unitPrice: Number(t.unit_price), + total: Number(t.total), + fees: Number(t.fees), + currency: t.currency as Currency, + date: t.date, + notes: t.notes, + })); + const prices: Record = {}; + for (const a of assets) prices[a.id] = a.price ? Number(a.price.price) : null; + + const { summarizePortfolio, calculatePortfolio } = await import( + "@/core/services/portfolio.service" + ); + const positions = calculatePortfolio(typedAssets, typedTxs, prices); + const summary = summarizePortfolio(positions); + defaults.initialAmount = summary.totalValue.toNumber(); + + const { data: profile } = await supabase + .from("profiles") + .select("expected_rates") + .eq("id", user.id) + .single(); + if (profile) { + const rates = profile.expected_rates as unknown as Record; + const avg = + Object.values(rates).reduce((s, v) => s + v, 0) / Object.values(rates).length; + defaults.annualRate = Number((avg * 100).toFixed(1)); + } + } + + return ( + <> +
+

Simulador “E se...?”

+

+ Compare o cenário atual e simule mudanças no aporte, retiradas ou aplicações extras. +

+
+ + + ); +} \ No newline at end of file diff --git a/app/transactions/actions.ts b/app/transactions/actions.ts new file mode 100644 index 0000000..3bf1494 --- /dev/null +++ b/app/transactions/actions.ts @@ -0,0 +1,108 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; +import { z } from "zod"; +import { createClient } from "@/lib/supabase/server"; +import type { TransactionType, Currency } from "@/lib/supabase/types"; + +const transactionSchema = z.object({ + assetId: z.string().uuid("Selecione um ativo válido"), + type: z.enum(["BUY", "SELL", "DIVIDEND", "INCOME", "SPLIT", "BONUS"]), + quantity: z.coerce.number().min(0, "Quantidade deve ser ≥ 0"), + unitPrice: z.coerce.number().min(0, "Preço unitário deve ser ≥ 0"), + fees: z.coerce.number().min(0, "Taxas devem ser ≥ 0").default(0), + currency: z.enum(["BRL", "USD"]), + date: z.string().min(1, "Data obrigatória"), + notes: z.string().trim().max(500).optional().nullable(), +}); + +export type TransactionActionResult = { + error?: string; + fieldErrors?: Record; +}; + +export async function createTransactionAction( + _prev: TransactionActionResult, + formData: FormData, +): Promise { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) return { error: "Não autenticado." }; + + const raw: Record = {}; + for (const [k, v] of formData.entries()) { + raw[k] = typeof v === "string" ? v : v.name; + } + + const parsed = transactionSchema.safeParse(raw); + if (!parsed.success) { + const fieldErrors: Record = {}; + for (const issue of parsed.error.issues) { + fieldErrors[issue.path.join(".")] = issue.message; + } + return { fieldErrors }; + } + + const data = parsed.data; + const qty = data.quantity; + const total = qty * data.unitPrice + data.fees; + + const { error } = await supabase + .from("transactions") + .insert({ + user_id: user.id, + asset_id: data.assetId, + type: data.type, + quantity: qty, + unit_price: data.unitPrice, + total, + fees: data.fees, + currency: data.currency, + date: data.date, + notes: data.notes || null, + }) + .select() + .single(); + + if (error) return { error: error.message }; + + revalidatePath("/transactions"); + revalidatePath("/portfolio"); + revalidatePath("/portfolio/holdings"); + redirect("/transactions"); +} + +export async function deleteTransactionAction(formData: FormData) { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) return; + + const id = String(formData.get("id") ?? ""); + if (!id) return; + + await supabase + .from("transactions") + .delete() + .eq("id", id) + .eq("user_id", user.id); + + revalidatePath("/transactions"); + revalidatePath("/portfolio"); + revalidatePath("/portfolio/holdings"); +} + +export type TransactionFormValues = { + assetId: string; + type: TransactionType; + quantity: number; + unitPrice: number; + fees: number; + currency: Currency; + date: string; + notes: string | null; +}; \ No newline at end of file diff --git a/app/transactions/layout.tsx b/app/transactions/layout.tsx new file mode 100644 index 0000000..aa05e44 --- /dev/null +++ b/app/transactions/layout.tsx @@ -0,0 +1,10 @@ +import type { ReactNode } from "react"; +import { PortfolioShell } from "@/components/portfolio/PortfolioShell"; + +export default function TransactionsLayout({ + children, +}: { + children: ReactNode; +}) { + return {children}; +} \ No newline at end of file diff --git a/app/transactions/new/page.tsx b/app/transactions/new/page.tsx new file mode 100644 index 0000000..b9c9a8f --- /dev/null +++ b/app/transactions/new/page.tsx @@ -0,0 +1,27 @@ +import Link from "next/link"; +import { ArrowLeft } from "lucide-react"; + +import { TransactionForm } from "@/components/portfolio/TransactionForm"; + +export default function NewTransactionPage() { + return ( + <> + + + Voltar + +
+

Nova transação

+

+ Registre uma compra, venda, dividendo ou rendimento. +

+
+
+ +
+ + ); +} \ No newline at end of file diff --git a/app/transactions/page.tsx b/app/transactions/page.tsx new file mode 100644 index 0000000..b955832 --- /dev/null +++ b/app/transactions/page.tsx @@ -0,0 +1,219 @@ +import Link from "next/link"; +import { + Plus, + Receipt, + ArrowDownCircle, + ArrowUpCircle, + Trash2, +} from "lucide-react"; + +import { createClient } from "@/lib/supabase/server"; +import { Card, CardContent } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { TRANSACTION_TYPE_LABELS, ASSET_CLASS_META } from "@/core/domain/asset-classes"; +import { deleteTransactionAction } from "@/app/transactions/actions"; +import type { AssetType, TransactionType, Currency } from "@/lib/supabase/types"; + +interface TxRow { + id: string; + asset_id: string; + asset_ticker: string; + asset_name: string; + asset_type: AssetType; + type: TransactionType; + quantity: number; + unit_price: number; + total: number; + fees: number; + currency: Currency; + date: string; + notes: string | null; +} + +function formatCurrency(value: number, currency: string) { + return new Intl.NumberFormat("pt-BR", { + style: "currency", + currency, + minimumFractionDigits: 2, + }).format(value); +} + +function formatDate(iso: string) { + const [y, m, d] = iso.split("-"); + return `${d}/${m}/${y}`; +} + +export default async function TransactionsPage() { + const supabase = await createClient(); + const { data } = await supabase + .from("transactions") + .select( + "id, asset_id, type, quantity, unit_price, total, fees, currency, date, notes, assets!inner(ticker, name, type)", + ) + .order("date", { ascending: false }) + .limit(200); + + const transactions: TxRow[] = (data ?? []).map((row: Record) => { + const asset = row.assets as { ticker: string; name: string; type: AssetType }; + return { + id: row.id as string, + asset_id: row.asset_id as string, + asset_ticker: asset.ticker, + asset_name: asset.name, + asset_type: asset.type, + type: row.type as TransactionType, + quantity: row.quantity as number, + unit_price: row.unit_price as number, + total: row.total as number, + fees: row.fees as number, + currency: row.currency as Currency, + date: row.date as string, + notes: row.notes as string | null, + }; + }); + + const totalCount = transactions.length; + + return ( + <> +
+
+

Transações

+

+ {totalCount} {totalCount === 1 ? "registro" : "registros"} (últimos 200) +

+
+ +
+ + {transactions.length === 0 ? ( + + +
+ +
+
+

Nenhuma transação registrada

+

+ Comece registrando suas compras, vendas ou dividendos. +

+
+ +
+
+ ) : ( + + +
+ + + + + + + + + + + + + + + {transactions.map((tx) => { + const meta = ASSET_CLASS_META[tx.asset_type]; + const isBuy = tx.type === "BUY"; + const isSell = tx.type === "SELL"; + const isIncome = tx.type === "DIVIDEND" || tx.type === "INCOME"; + return ( + + + + + + + + + + + ); + })} + +
DataAtivoTipoQtdPreçoTotalObs
+ {formatDate(tx.date)} + + + {tx.asset_ticker} + + {meta.label} + + + + + {isBuy ? ( + + ) : isSell ? ( + + ) : null} + {TRANSACTION_TYPE_LABELS[tx.type]} + + + {tx.quantity > 0 ? tx.quantity.toLocaleString("pt-BR") : "—"} + + {tx.unit_price > 0 + ? formatCurrency(tx.unit_price, tx.currency) + : "—"} + + {tx.total > 0 + ? formatCurrency(tx.total, tx.currency) + : "—"} + + {tx.notes ?? "—"} + +
+ + +
+
+
+
+
+ )} + + ); +} \ No newline at end of file diff --git a/middleware.ts b/middleware.ts new file mode 100644 index 0000000..a460b62 --- /dev/null +++ b/middleware.ts @@ -0,0 +1,13 @@ +import { updateSession } from "@/lib/supabase/middleware"; +import type { NextRequest } from "next/server"; + +export async function middleware(request: NextRequest) { + return await updateSession(request); +} + +export const config = { + matcher: [ + // Executa em todas as rotas exceto estáticos e imagens + "/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)", + ], +}; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index a554c76..d3027a1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,6 +36,8 @@ "@radix-ui/react-toggle": "^1.1.9", "@radix-ui/react-toggle-group": "^1.1.10", "@radix-ui/react-tooltip": "^1.2.7", + "@supabase/ssr": "^0.12.7", + "@supabase/supabase-js": "^2.117.2", "@tanstack/react-query": "^5.83.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -55,6 +57,7 @@ "react-hook-form": "^7.61.1", "react-resizable-panels": "^2.1.9", "recharts": "^2.15.4", + "server-only": "^0.0.1", "sonner": "^1.7.4", "tailwind-merge": "^2.6.1", "tailwindcss-animate": "^1.0.7", @@ -4207,6 +4210,110 @@ "dev": true, "license": "MIT" }, + "node_modules/@supabase/auth-js": { + "version": "2.117.2", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.117.2.tgz", + "integrity": "sha512-Z3WnGwrphYZubrLbxp5Iv0tLA9A5GhvKzJ/ZXglxqwd2QEH2R4dXRltjVJ8sIn4xEW2BSFIGT0yALVJIgZeDYw==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/functions-js": { + "version": "2.117.2", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.117.2.tgz", + "integrity": "sha512-6DT4ZIjmZxa9ANKaBIrjc86AyKrX8M376ynvNeOBBVad3eP7x3UHqWfDR2ynFkHGzCuOpr1A+kObmnahxuwuog==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/phoenix": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.5.tgz", + "integrity": "sha512-aAn9H9ovVyeApKy11OWOrrOGq8DV68yWeH4ud2lN9fzn4aO8Zb5GLL9m1pUg9nLqIcT+ZDfAcsZe0E/nqdv2lw==", + "license": "MIT" + }, + "node_modules/@supabase/postgrest-js": { + "version": "2.117.2", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.117.2.tgz", + "integrity": "sha512-V1Qhn+M8xzJqCasOxHZ2KG19Fj39PxU7weEAmOK65/KTsWCRXboB4hcQwFvxRvM7ZikeP9IZWNvQkfjvSxzYFw==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/realtime-js": { + "version": "2.117.2", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.117.2.tgz", + "integrity": "sha512-lYXSAIg3eAKA58riUED6Vb+TCzF8jtx18uOoiCaVJ7ZMre6FWBpwB1MPeW+B6vykJT/hPdiuqwcSB7ILMS21ew==", + "license": "MIT", + "dependencies": { + "@supabase/phoenix": "0.4.5", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/ssr": { + "version": "0.12.7", + "resolved": "https://registry.npmjs.org/@supabase/ssr/-/ssr-0.12.7.tgz", + "integrity": "sha512-wiBtEie1KkRJi9RrZWY3R2imRhX1JY7qMyUCH2z9AUk15gQebNEplM+urbCKamdxaTJLXUU6LlpkJsaxhojCEg==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.2" + }, + "peerDependencies": { + "@supabase/supabase-js": "^2.114.0" + } + }, + "node_modules/@supabase/storage-js": { + "version": "2.117.2", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.117.2.tgz", + "integrity": "sha512-8gAJoVaxZa/War2kFRfJMxGk4M190Q7lJ70BofwGlRxU/u9pIodd6XyTvmXkxOKXWuIYtkDaYPZrIAUt1T/7FQ==", + "license": "MIT", + "dependencies": { + "iceberg-js": "^0.8.1", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/supabase-js": { + "version": "2.117.2", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.117.2.tgz", + "integrity": "sha512-eSG2VKnHR+Clp1PmidZ1/weJ8PJwoybjva3L2GgKqFG4YDS1Iqmc61psKGZP5xw6OMT2O7ZorPR42PY6q1BOXg==", + "license": "MIT", + "dependencies": { + "@supabase/auth-js": "2.117.2", + "@supabase/functions-js": "2.117.2", + "@supabase/postgrest-js": "2.117.2", + "@supabase/realtime-js": "2.117.2", + "@supabase/storage-js": "2.117.2" + }, + "engines": { + "node": ">=22.0.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + } + } + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -6262,6 +6369,19 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -8154,6 +8274,15 @@ "node": ">= 14" } }, + "node_modules/iceberg-js": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", + "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -10811,6 +10940,12 @@ "semver": "bin/semver.js" } }, + "node_modules/server-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/server-only/-/server-only-0.0.1.tgz", + "integrity": "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==", + "license": "MIT" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", diff --git a/package.json b/package.json index aa481f5..b5b841b 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,8 @@ "@radix-ui/react-toggle": "^1.1.9", "@radix-ui/react-toggle-group": "^1.1.10", "@radix-ui/react-tooltip": "^1.2.7", + "@supabase/ssr": "^0.12.7", + "@supabase/supabase-js": "^2.117.2", "@tanstack/react-query": "^5.83.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -62,6 +64,7 @@ "react-hook-form": "^7.61.1", "react-resizable-panels": "^2.1.9", "recharts": "^2.15.4", + "server-only": "^0.0.1", "sonner": "^1.7.4", "tailwind-merge": "^2.6.1", "tailwindcss-animate": "^1.0.7", diff --git a/src/components/CalculatorLayout.tsx b/src/components/CalculatorLayout.tsx index 216625b..a51f156 100644 --- a/src/components/CalculatorLayout.tsx +++ b/src/components/CalculatorLayout.tsx @@ -4,6 +4,7 @@ import { ArrowLeft } from "lucide-react"; import { Button } from "@/components/ui/button"; import { ForgeLogo } from "@/public/ForgeLogo"; import { ThemeToggle } from "@/components/theme-toggle"; +import { UserMenu } from "@/components/auth/UserMenu"; interface CalculatorLayoutProps { children: ReactNode; @@ -37,6 +38,7 @@ export function CalculatorLayout({ + diff --git a/src/components/Dashboard/PortfolioTeaser.tsx b/src/components/Dashboard/PortfolioTeaser.tsx new file mode 100644 index 0000000..b544fa4 --- /dev/null +++ b/src/components/Dashboard/PortfolioTeaser.tsx @@ -0,0 +1,184 @@ +import Link from "next/link"; +import { ArrowRight, TrendingUp, Wallet } from "lucide-react"; + +import { createClient } from "@/lib/supabase/server"; +import { + fetchAssetsWithPrices, + fetchTransactions, +} from "@/core/data/portfolio-data"; +import { + calculatePortfolio, + summarizePortfolio, +} from "@/core/services/portfolio.service"; +import { summarizeInBRL } from "@/core/services/portfolio-aggregator.service"; +import { Card, CardContent } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import type { Asset, Transaction } from "@/core/domain/portfolio.types"; +import type { AssetType, Currency } from "@/lib/supabase/types"; + +function formatBRL(value: number) { + return new Intl.NumberFormat("pt-BR", { + style: "currency", + currency: "BRL", + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }).format(value); +} + +function formatPercent(value: number) { + const sign = value > 0 ? "+" : ""; + return `${sign}${value.toFixed(2)}%`; +} + +interface PortfolioSummaryCardProps { + userId: string; +} + +async function PortfolioSummaryCard({ userId }: PortfolioSummaryCardProps) { + const [assets, txs] = await Promise.all([ + fetchAssetsWithPrices(userId), + fetchTransactions(userId), + ]); + + if (assets.length === 0) return null; + + const typedAssets: Asset[] = assets.map((a) => ({ + id: a.id, + ticker: a.ticker, + name: a.name, + type: a.type as AssetType, + currency: a.currency as Currency, + exchange: a.exchange, + sector: a.sector, + metadata: (a.metadata as Record) ?? {}, + })); + const typedTxs: Transaction[] = txs.map((t) => ({ + id: t.id, + assetId: t.asset_id, + type: t.type, + quantity: Number(t.quantity), + unitPrice: Number(t.unit_price), + total: Number(t.total), + fees: Number(t.fees), + currency: t.currency as Currency, + date: t.date, + notes: t.notes, + })); + const prices: Record = {}; + for (const a of assets) prices[a.id] = a.price ? Number(a.price.price) : null; + + const positions = calculatePortfolio(typedAssets, typedTxs, prices); + const summary = summarizePortfolio(positions); + const inBRL = await summarizeInBRL(positions); + + return ( + + +
+
+
+ +
+
+

Sua carteira

+

+ {summary.positionCount} {summary.positionCount === 1 ? "ativo" : "ativos"} +

+
+
+ +
+
+
+

+ Patrimônio +

+

+ {inBRL.totalValueBRL.toNumber() > 0 + ? formatBRL(inBRL.totalValueBRL.toNumber()) + : "—"} +

+
+
+

+ Custo total +

+

+ {formatBRL(inBRL.totalCostBRL.toNumber())} +

+
+
+

+ Retorno +

+

= 0 + ? "text-emerald-600" + : "text-red-600" + }`} + > + {inBRL.totalCostBRL.toNumber() > 0 + ? formatBRL(inBRL.totalReturnBRL.toNumber()) + : "—"} + {inBRL.totalCostBRL.toNumber() > 0 && ( + + ({formatPercent(inBRL.totalReturnPercent.toNumber())}) + + )} +

+
+
+
+
+ ); +} + +async function PortfolioCTACard() { + return ( + + +
+ +
+
+

Sua carteira está esperando por você

+

+ Comece cadastrando seus primeiros ativos e transações. +

+
+
+ +
+
+
+ ); +} + +export async function PortfolioTeaser() { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) return null; + + const { count } = await supabase + .from("assets") + .select("id", { count: "exact", head: true }) + .eq("user_id", user.id); + + if (!count || count === 0) { + return ; + } + return ; +} \ No newline at end of file diff --git a/src/components/Dashboard/index.tsx b/src/components/Dashboard/index.tsx index 0dd57be..e9b240c 100644 --- a/src/components/Dashboard/index.tsx +++ b/src/components/Dashboard/index.tsx @@ -4,6 +4,7 @@ import { motion, useScroll, useTransform } from "framer-motion"; import { ForgeLogo } from "@/public/ForgeLogo"; import { FeatureCard } from "@/components/FeatureCard"; import { ThemeToggle } from "@/components/theme-toggle"; +import { UserMenu } from "@/components/auth/UserMenu"; import { Button } from "@/components/ui/button"; import { TrendingUp, @@ -142,6 +143,7 @@ export function Dashboard() { Precisão financeira + diff --git a/src/components/auth/AuthForm.tsx b/src/components/auth/AuthForm.tsx new file mode 100644 index 0000000..ef0bf72 --- /dev/null +++ b/src/components/auth/AuthForm.tsx @@ -0,0 +1,169 @@ +"use client"; + +import { useState, useTransition } from "react"; +import Link from "next/link"; +import { motion } from "framer-motion"; +import { LogIn, Mail, Lock, User, Loader2 } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { loginAction, signupAction } from "@/app/(auth)/actions"; + +interface AuthFormProps { + mode: "login" | "signup"; +} + +export function AuthForm({ mode }: AuthFormProps) { + const [isPending, startTransition] = useTransition(); + const [error, setError] = useState(null); + + const isLogin = mode === "login"; + const action = isLogin ? loginAction : signupAction; + + function handleSubmit(formData: FormData) { + setError(null); + startTransition(async () => { + const result = await action(formData); + if (result?.error) { + setError(result.error); + } + }); + } + + return ( + +
+
+
+
+ + + +

+ {isLogin ? "Entrar" : "Criar conta"} +

+

+ {isLogin + ? "Acesse sua carteira FinForge" + : "Comece a gerenciar sua carteira"} +

+
+ +
+ {!isLogin && ( +
+ +
+ + +
+
+ )} + +
+ +
+ + +
+
+ +
+ +
+ + +
+
+ + {error && ( + + {error} + + )} + + +
+ +
+ {isLogin ? ( + <> + Não tem conta?{" "} + + Criar agora + + + ) : ( + <> + Já tem conta?{" "} + + Entrar + + + )} +
+
+
+ + ); +} \ No newline at end of file diff --git a/src/components/auth/UserMenu.tsx b/src/components/auth/UserMenu.tsx new file mode 100644 index 0000000..5cb9afb --- /dev/null +++ b/src/components/auth/UserMenu.tsx @@ -0,0 +1,109 @@ +"use client"; + +import Link from "next/link"; +import { useTransition } from "react"; +import { LogOut, User as UserIcon, Wallet, LayoutDashboard, Settings } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { useAuth } from "@/hooks/useAuth"; +import { logoutAction } from "@/app/(auth)/actions"; + +export function UserMenu() { + const { user, loading } = useAuth(); + const [isPending, startTransition] = useTransition(); + + function handleLogout() { + startTransition(async () => { + await logoutAction(); + }); + } + + if (loading) { + return
; + } + + if (!user) { + return ( + + ); + } + + const initials = (user.user_metadata.display_name as string | undefined) + ?.split(" ") + .map((s) => s[0]) + .slice(0, 2) + .join("") + .toUpperCase() ?? user.email?.[0]?.toUpperCase() ?? "?"; + + return ( + + + + + + +
+

+ {(user.user_metadata.display_name as string | undefined) ?? + "Usuário"} +

+

{user.email}

+
+
+ + + + + Carteira + + + + + + Dashboard + + + + + + Perfil + + + + + + Backup + + + + + + Sair + +
+
+ ); +} \ No newline at end of file diff --git a/src/components/portfolio/AllocationView.tsx b/src/components/portfolio/AllocationView.tsx new file mode 100644 index 0000000..ef3f64b --- /dev/null +++ b/src/components/portfolio/AllocationView.tsx @@ -0,0 +1,201 @@ +import { AlertTriangle, PieChart } from "lucide-react"; +import { + Pie, + PieChart as RechartsPie, + Cell, + ResponsiveContainer, + Tooltip, +} from "recharts"; + +import { ASSET_CLASS_META } from "@/core/domain/asset-classes"; +import { Card, CardContent } from "@/components/ui/card"; +import type { AllocationResult } from "@/core/services/allocation.service"; + +interface AllocationViewProps { + allocation: AllocationResult; +} + +function formatBRL(value: number) { + return new Intl.NumberFormat("pt-BR", { + style: "currency", + currency: "BRL", + minimumFractionDigits: 0, + }).format(value); +} + +export function AllocationView({ allocation }: AllocationViewProps) { + const { byClass, byAsset, concentrationWarnings, total } = allocation; + + if (total.toNumber() === 0) { + return ( + + +
+ +
+
+

Sem dados de alocação

+

+ Adicione ativos com cotações atualizadas para visualizar a diversificação. +

+
+
+
+ ); + } + + const pieData = byClass.map((c) => ({ + name: ASSET_CLASS_META[c.type].labelPlural, + value: c.value.toNumber(), + pct: c.percentage.toNumber(), + color: ASSET_CLASS_META[c.type].color, + })); + + return ( +
+
+ {/* Pizza */} + + +

Distribuição por classe

+
+ + + + {pieData.map((entry, i) => ( + + ))} + + formatBRL(value)} + contentStyle={{ + backgroundColor: "hsl(var(--card))", + border: "2px solid hsl(var(--border))", + borderRadius: "12px", + }} + /> + + +
+
+
+ + {/* Lista */} + + +

% por classe

+
+ {byClass.map((c) => { + const meta = ASSET_CLASS_META[c.type]; + const pct = c.percentage.toNumber(); + return ( +
+
+
+ + {meta.labelPlural} + + ({c.positionCount} {c.positionCount === 1 ? "ativo" : "ativos"}) + +
+
+ {pct.toFixed(1)}% + + {formatBRL(c.value.toNumber())} + +
+
+
+
+
+
+ ); + })} +
+ + +
+ + {concentrationWarnings.length > 0 && ( + + +
+ +
+

+ Alerta de concentração +

+

+ Os seguintes ativos ultrapassam 20% da posição: +

+
    + {concentrationWarnings.map((w) => ( +
  • + {w.ticker}:{" "} + + {w.percentage.toFixed(1)}% + +
  • + ))} +
+
+
+
+
+ )} + + + +

Todos os ativos

+
+ + + + + + + + + + {byAsset + .sort((a, b) => b.percentage.minus(a.percentage).toNumber()) + .map((a) => ( + + + + + + ))} + +
TickerValor%
{a.ticker} + {formatBRL(a.value.toNumber())} + + {a.percentage.toFixed(2)}% +
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/portfolio/AssetForm.tsx b/src/components/portfolio/AssetForm.tsx new file mode 100644 index 0000000..24726bf --- /dev/null +++ b/src/components/portfolio/AssetForm.tsx @@ -0,0 +1,301 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z as ZodNS } from "zod"; +import { Loader2 } from "lucide-react"; + +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + ASSET_TYPE_OPTIONS, + FIXED_INCOME_INDEXER_OPTIONS, + FIXED_INCOME_KIND_OPTIONS, +} from "@/core/domain/portfolio-constants"; +import { + createAssetAction, + updateAssetAction, + type AssetFormValues, +} from "@/app/assets/actions"; + +const schema = ZodNS.object({ + ticker: ZodNS.string().trim().min(1, "Ticker obrigatório").max(20), + name: ZodNS.string().trim().min(1, "Nome obrigatório").max(100), + type: ZodNS.enum(["STOCK", "ETF", "FII", "CRYPTO", "FIXED_INCOME", "CASH"]), + currency: ZodNS.enum(["BRL", "USD"]), + exchange: ZodNS.string().trim().max(50).optional().or(ZodNS.literal("")), + sector: ZodNS.string().trim().max(50).optional().or(ZodNS.literal("")), +}); + +interface AssetFormProps { + defaultValues?: Partial; + mode: "create" | "edit"; +} + +export function AssetForm({ defaultValues, mode }: AssetFormProps) { + const [isPending, startTransition] = useTransition(); + const [serverError, setServerError] = useState(null); + const [fieldErrors, setFieldErrors] = useState>({}); + + const initialType = defaultValues?.type ?? "STOCK"; + + const { + register, + handleSubmit, + watch, + setValue, + formState: { errors }, + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + ticker: defaultValues?.ticker ?? "", + name: defaultValues?.name ?? "", + type: initialType, + currency: defaultValues?.currency ?? "BRL", + exchange: defaultValues?.exchange ?? "", + sector: defaultValues?.sector ?? "", + }, + }); + + const selectedType = watch("type"); + const isRF = selectedType === "FIXED_INCOME"; + + function onSubmit(values: Record) { + setServerError(null); + setFieldErrors({}); + + const formData = new FormData(); + formData.set("ticker", String(values.ticker ?? "")); + formData.set("name", String(values.name ?? "")); + formData.set("type", String(values.type ?? "")); + formData.set("currency", String(values.currency ?? "BRL")); + formData.set("exchange", String(values.exchange ?? "")); + formData.set("sector", String(values.sector ?? "")); + + if (mode === "edit" && defaultValues?.id) { + formData.set("id", defaultValues.id); + } + + if (values.type === "FIXED_INCOME") { + const kindEl = document.getElementById("fi-kind") as HTMLInputElement | null; + const indexerEl = document.getElementById("fi-indexer") as HTMLInputElement | null; + const rateEl = document.getElementById("fi-rate") as HTMLInputElement | null; + const maturityEl = document.getElementById("fi-maturity") as HTMLInputElement | null; + const issuerEl = document.getElementById("fi-issuer") as HTMLInputElement | null; + if (kindEl) formData.set("fixedIncome.kind", kindEl.value); + if (indexerEl) formData.set("fixedIncome.indexer", indexerEl.value); + if (rateEl) formData.set("fixedIncome.rate", rateEl.value); + if (maturityEl) formData.set("fixedIncome.maturityDate", maturityEl.value); + if (issuerEl) formData.set("fixedIncome.issuer", issuerEl.value); + } + + startTransition(async () => { + const action = mode === "create" ? createAssetAction : updateAssetAction; + const result = await action({}, formData); + if (result?.error) setServerError(result.error); + if (result?.fieldErrors) setFieldErrors(result.fieldErrors); + }); + } + + return ( +
+
+
+ + +
+ +
+ + +
+
+ +
+
+ + + {errors.ticker && ( +

{errors.ticker.message as string}

+ )} + {fieldErrors.ticker && ( +

{fieldErrors.ticker}

+ )} +
+ +
+ + + {errors.name && ( +

{errors.name.message as string}

+ )} +
+
+ + {(selectedType === "STOCK" || + selectedType === "ETF" || + selectedType === "FII" || + selectedType === "CRYPTO") && ( +
+
+ + +
+
+ + +
+
+ )} + + {isRF && ( +
+

Detalhes de Renda Fixa

+
+
+ + +
+
+ + +
+
+ + +

+ Ex: 110 para 110% do CDI; 13.5 para 13,5% a.a.; 6 para IPCA+6%. +

+
+
+ + +
+
+ + +
+
+
+ )} + + {serverError && ( +

{serverError}

+ )} + +
+ + +
+
+ ); +} \ No newline at end of file diff --git a/src/components/portfolio/DividendsChart.tsx b/src/components/portfolio/DividendsChart.tsx new file mode 100644 index 0000000..598cf6a --- /dev/null +++ b/src/components/portfolio/DividendsChart.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { + Bar, + BarChart, + CartesianGrid, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; + +interface DividendsChartProps { + data: Array<{ month: string; total: number }>; +} + +function formatBRL(value: number) { + return new Intl.NumberFormat("pt-BR", { + style: "currency", + currency: "BRL", + minimumFractionDigits: 2, + }).format(value); +} + +export function DividendsChart({ data }: DividendsChartProps) { + if (data.length === 0) return null; + + return ( +
+ + + + + + v >= 1000 ? `R$ ${(v / 1000).toFixed(1)}k` : `R$ ${v}` + } + className="text-xs" + axisLine={false} + tickLine={false} + /> + formatBRL(value)} + contentStyle={{ + backgroundColor: "hsl(var(--card))", + border: "2px solid hsl(var(--border))", + borderRadius: "12px", + }} + /> + + + +
+ ); +} \ No newline at end of file diff --git a/src/components/portfolio/PortfolioSettings.tsx b/src/components/portfolio/PortfolioSettings.tsx new file mode 100644 index 0000000..24fff1b --- /dev/null +++ b/src/components/portfolio/PortfolioSettings.tsx @@ -0,0 +1,131 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { Download, Upload, Loader2, CheckCircle2 } from "lucide-react"; + +import { Card, CardContent } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { exportPortfolioAction, importPortfolioAction } from "@/app/portfolio/settings/actions"; + +export function PortfolioSettings() { + const [isExporting, startExport] = useTransition(); + const [isImporting, startImport] = useTransition(); + const [message, setMessage] = useState<{ type: "ok" | "err"; text: string } | null>(null); + + function handleExport() { + setMessage(null); + startExport(async () => { + const result = await exportPortfolioAction(); + if (!result.ok || !result.data) { + setMessage({ type: "err", text: result.error ?? "Erro ao exportar." }); + return; + } + const blob = new Blob([JSON.stringify(result.data, null, 2)], { + type: "application/json", + }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `finforge-${new Date().toISOString().substring(0, 10)}.json`; + a.click(); + URL.revokeObjectURL(url); + setMessage({ type: "ok", text: "Backup exportado com sucesso." }); + }); + } + + function handleImport(formData: FormData) { + setMessage(null); + startImport(async () => { + const result = await importPortfolioAction(undefined, formData); + if (result.error) { + setMessage({ type: "err", text: result.error }); + return; + } + setMessage({ + type: "ok", + text: `Importação concluída (${result.imported ?? 0} ativos).`, + }); + }); + } + + return ( +
+ + +

+ + Exportar carteira +

+

+ Baixe um arquivo JSON com todos os seus ativos, transações e detalhes de + renda fixa. Útil para backup ou migração. +

+ +
+
+ + + +

+ + Importar carteira +

+

+ Carregue um arquivo JSON exportado anteriormente. Ativos serão criados novamente + na sua conta. +

+
handleImport(fd)} + className="flex flex-col sm:flex-row gap-3" + > + + +
+
+
+ + {message && ( +
+ {message.type === "ok" && } +

{message.text}

+
+ )} +
+ ); +} \ No newline at end of file diff --git a/src/components/portfolio/PortfolioShell.tsx b/src/components/portfolio/PortfolioShell.tsx new file mode 100644 index 0000000..ad6dc34 --- /dev/null +++ b/src/components/portfolio/PortfolioShell.tsx @@ -0,0 +1,48 @@ +import Link from "next/link"; +import { redirect } from "next/navigation"; +import type { ReactNode } from "react"; + +import { createClient } from "@/lib/supabase/server"; +import { ForgeLogo } from "@/public/ForgeLogo"; +import { ThemeToggle } from "@/components/theme-toggle"; +import { UserMenu } from "@/components/auth/UserMenu"; +import { PortfolioSidebar } from "@/components/portfolio/PortfolioSidebar"; +import { PortfolioTabs } from "@/components/portfolio/PortfolioTabs"; + +export async function PortfolioShell({ + children, +}: { + children: ReactNode; +}) { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + + if (!user) { + redirect("/login"); + } + + return ( +
+
+
+ + + +
+ + +
+
+
+ + + +
+ +
{children}
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/portfolio/PortfolioSidebar.tsx b/src/components/portfolio/PortfolioSidebar.tsx new file mode 100644 index 0000000..3c7dd6b --- /dev/null +++ b/src/components/portfolio/PortfolioSidebar.tsx @@ -0,0 +1,99 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { + LayoutDashboard, + Coins, + Receipt, + PieChart, + LineChart, + Wallet, + Sparkles, + ChevronLeft, + DollarSign, +} from "lucide-react"; + +import { cn } from "@/lib/utils"; + +const sections = [ + { href: "/portfolio", label: "Visão geral", icon: LayoutDashboard }, + { href: "/portfolio/holdings", label: "Posições", icon: Coins }, + { href: "/transactions", label: "Transações", icon: Receipt }, + { href: "/portfolio/allocation", label: "Alocação", icon: PieChart }, + { href: "/portfolio/projection", label: "Projeção", icon: LineChart }, + { href: "/portfolio/simulator", label: "Simulador", icon: Sparkles }, + { href: "/portfolio/dividends", label: "Proventos", icon: DollarSign }, +]; + +const quick = [ + { href: "/assets/new", label: "Cadastrar ativo", icon: Wallet, primary: true }, + { href: "/transactions/new", label: "Nova transação", icon: Sparkles }, +]; + +export function PortfolioSidebar() { + const pathname = usePathname(); + + return ( + + ); +} \ No newline at end of file diff --git a/src/components/portfolio/PortfolioTabs.tsx b/src/components/portfolio/PortfolioTabs.tsx new file mode 100644 index 0000000..60c4862 --- /dev/null +++ b/src/components/portfolio/PortfolioTabs.tsx @@ -0,0 +1,46 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { LayoutDashboard, Coins, Receipt, PieChart, LineChart, Sparkles, DollarSign } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +const sections = [ + { href: "/portfolio", label: "Resumo", icon: LayoutDashboard }, + { href: "/portfolio/holdings", label: "Posições", icon: Coins }, + { href: "/transactions", label: "Extrato", icon: Receipt }, + { href: "/portfolio/allocation", label: "Alocação", icon: PieChart }, + { href: "/portfolio/projection", label: "Projeção", icon: LineChart }, + { href: "/portfolio/simulator", label: "Simulador", icon: Sparkles }, + { href: "/portfolio/dividends", label: "Proventos", icon: DollarSign }, +]; + +export function PortfolioTabs() { + const pathname = usePathname(); + + return ( +
+
+ {sections.map((s) => { + const active = pathname === s.href; + return ( + + + {s.label} + + ); + })} +
+
+ ); +} \ No newline at end of file diff --git a/src/components/portfolio/ProjectionPanel.tsx b/src/components/portfolio/ProjectionPanel.tsx new file mode 100644 index 0000000..ce406fb --- /dev/null +++ b/src/components/portfolio/ProjectionPanel.tsx @@ -0,0 +1,344 @@ +"use client"; + +import { useMemo, useState } from "react"; +import Decimal from "decimal.js"; +import { + Area, + AreaChart, + CartesianGrid, + Legend, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; + +import { Card, CardContent } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { SliderInputField } from "@/components/calculator/SliderInputField"; +import { projectPortfolio } from "@/core/services/projection.service"; + +function formatBRL(value: number) { + return new Intl.NumberFormat("pt-BR", { + style: "currency", + currency: "BRL", + minimumFractionDigits: 0, + }).format(value); +} + +function formatCompact(value: number) { + if (value >= 1_000_000) return `R$ ${(value / 1_000_000).toFixed(1)}M`; + if (value >= 1_000) return `R$ ${(value / 1_000).toFixed(0)}K`; + return `R$ ${value.toFixed(0)}`; +} + +interface ProjectionControlsProps { + initialAmount: number; + monthlyContribution: number; + annualRate: number; + years: number; + ipca: number; + inflationAdjusted: boolean; + onChange: (values: { + initialAmount: number; + monthlyContribution: number; + annualRate: number; + years: number; + ipca: number; + inflationAdjusted: boolean; + }) => void; +} + +export function ProjectionControls({ + initialAmount, + monthlyContribution, + annualRate, + years, + ipca, + inflationAdjusted, + onChange, +}: ProjectionControlsProps) { + function update(patch: Partial) { + onChange({ + initialAmount, + monthlyContribution, + annualRate, + years, + ipca, + inflationAdjusted, + ...patch, + }); + } + + return ( + + +
+ + update({ initialAmount: Number(e.target.value) })} + /> +

+ Valor atual da carteira ou ponto de partida. +

+
+ + update({ monthlyContribution: v })} + displayValue={formatBRL(monthlyContribution)} + min={0} + max={20000} + step={100} + /> + + update({ annualRate: v })} + displayValue={`${annualRate}%`} + inputStep="0.1" + min={0} + max={30} + step={0.5} + /> + + update({ years: Math.min(50, v) })} + displayValue={`years} anos`.replace("years", `${years}`)} + min={1} + max={40} + step={1} + /> + +
+
+
+ +

+ Mostrar poder de compra real (IPCA). +

+
+ update({ inflationAdjusted: c })} + /> +
+ {inflationAdjusted && ( +
+ + update({ ipca: Number(e.target.value) })} + /> +
+ )} +
+
+
+ ); +} + +interface ProjectionViewProps { + initialAmount: number; + monthlyContribution: number; + annualRate: number; + years: number; + ipca: number; + inflationAdjusted: boolean; +} + +export function ProjectionView({ + initialAmount, + monthlyContribution, + annualRate, + years, + ipca, + inflationAdjusted, +}: ProjectionViewProps) { + const result = useMemo( + () => + projectPortfolio({ + initialAmount: new Decimal(initialAmount), + monthlyContribution: new Decimal(monthlyContribution), + annualRate: new Decimal(annualRate), + years, + inflationRate: inflationAdjusted ? new Decimal(ipca) : undefined, + }), + [initialAmount, monthlyContribution, annualRate, years, inflationAdjusted, ipca], + ); + + const chartData = result.timeline.map((p) => ({ + yearLabel: p.yearLabel, + balance: p.balance.toNumber(), + contributed: p.totalContributed.toNumber(), + realBalance: p.realBalance?.toNumber(), + })); + + return ( +
+
+ + +

+ Patrimônio final +

+

+ {formatBRL(result.finalBalance.toNumber())} +

+
+
+ + +

+ Total aportado +

+

+ {formatBRL(result.totalContributed.toNumber())} +

+
+
+ + +

+ Juros ganhos +

+

+ {formatBRL(result.totalInterest.toNumber())} +

+
+
+
+ + {inflationAdjusted && result.realFinalBalance && ( + + +

+ Patrimônio real (descontando inflação) +

+

+ {formatBRL(result.realFinalBalance.toNumber())} +

+

+ Em poder de compra de hoje. +

+
+
+ )} + + + +
+ + + + + + + + + + + + + + + + + + + + formatBRL(value)} + contentStyle={{ + backgroundColor: "hsl(var(--card))", + border: "2px solid hsl(var(--border))", + borderRadius: "12px", + }} + /> + + + + {inflationAdjusted && ( + + )} + + +
+
+
+
+ ); +} + +interface ProjectionPanelProps { + defaults: { + initialAmount: number; + monthlyContribution: number; + annualRate: number; + years: number; + ipca: number; + }; +} + +export function ProjectionPanel({ defaults }: ProjectionPanelProps) { + const [values, setValues] = useState({ + initialAmount: defaults.initialAmount, + monthlyContribution: defaults.monthlyContribution, + annualRate: defaults.annualRate, + years: defaults.years, + ipca: defaults.ipca, + inflationAdjusted: false, + }); + + return ( +
+
+ +
+
+ +
+
+ ); +} \ No newline at end of file diff --git a/src/components/portfolio/RefreshPricesButton.tsx b/src/components/portfolio/RefreshPricesButton.tsx new file mode 100644 index 0000000..ee8a7d6 --- /dev/null +++ b/src/components/portfolio/RefreshPricesButton.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { useTransition } from "react"; +import { RefreshCw } from "lucide-react"; +import { Button } from "@/components/ui/button"; + +interface RefreshPricesButtonProps { + onRefresh?: () => void; +} + +export function RefreshPricesButton({ onRefresh }: RefreshPricesButtonProps) { + const [isPending, startTransition] = useTransition(); + + function handleClick() { + startTransition(async () => { + await fetch("/api/prices/refresh", { method: "POST" }); + onRefresh?.(); + // Refresh da página para refletir novas cotações + globalThis.location.reload(); + }); + } + + return ( + + ); +} \ No newline at end of file diff --git a/src/components/portfolio/Simulator.tsx b/src/components/portfolio/Simulator.tsx new file mode 100644 index 0000000..f73efb4 --- /dev/null +++ b/src/components/portfolio/Simulator.tsx @@ -0,0 +1,307 @@ +"use client"; + +import { useMemo, useState } from "react"; +import Decimal from "decimal.js"; +import { Sparkles } from "lucide-react"; +import { + Line, + LineChart, + CartesianGrid, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, + Legend, +} from "recharts"; + +import { Card, CardContent } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { SliderInputField } from "@/components/calculator/SliderInputField"; +// (import removido) + +function formatBRL(value: number) { + return new Intl.NumberFormat("pt-BR", { + style: "currency", + currency: "BRL", + minimumFractionDigits: 0, + }).format(value); +} + +function formatCompact(value: number) { + if (value >= 1_000_000) return `R$ ${(value / 1_000_000).toFixed(1)}M`; + if (value >= 1_000) return `R$ ${(value / 1_000).toFixed(0)}K`; + return `R$ ${value.toFixed(0)}`; +} + +interface SimulatorProps { + defaults: { + initialAmount: number; + monthlyContribution: number; + annualRate: number; + years: number; + }; +} + +export function Simulator({ defaults }: SimulatorProps) { + const [initial, setInitial] = useState(defaults.initialAmount); + const [contrib, setContrib] = useState(defaults.monthlyContribution); + const [extraAtMonth, setExtraAtMonth] = useState(0); // aporte extra no mês N + const [extraAmount, setExtraAmount] = useState(0); + const [monthlyWithdrawal, setMonthlyWithdrawal] = useState(0); + const [rate, setRate] = useState(defaults.annualRate); + const [years, setYears] = useState(defaults.years); + + // Cenário alternativo: incluir aporte extra + retirada + const scenario = useMemo(() => { + // Para simular aporte extra em mês N, projetamos manualmente: + const monthlyRate = new Decimal(1) + .plus(new Decimal(rate).div(100)) + .pow(new Decimal(1).div(12)) + .minus(new Decimal(1)); + let balance = new Decimal(initial); + const months = years * 12; + const timeline: { month: number; year: number; yearLabel: string; baseline: number; scenario: number }[] = []; + let baselineBalance = new Decimal(initial); + + timeline.push({ + month: 0, + year: 0, + yearLabel: "Hoje", + baseline: baselineBalance.toNumber(), + scenario: balance.toNumber(), + }); + + for (let m = 1; m <= months; m++) { + // baseline (sem mudanças) + baselineBalance = baselineBalance + .plus(contrib) + .times(new Decimal(1).plus(monthlyRate)); + + // scenario (com mudanças) + balance = balance.plus(contrib).minus(monthlyWithdrawal); + if (extraAtMonth > 0 && m === extraAtMonth) { + balance = balance.plus(extraAmount); + } + balance = balance.times(new Decimal(1).plus(monthlyRate)); + + if (m % 12 === 0) { + timeline.push({ + month: m, + year: m / 12, + yearLabel: `+${m / 12}a`, + baseline: baselineBalance.toNumber(), + scenario: balance.toNumber(), + }); + } + } + + return { + finalBalance: balance, + baseline: baselineBalance, + timeline, + }; + }, [initial, contrib, extraAmount, extraAtMonth, monthlyWithdrawal, rate, years]); + + const chartData = scenario.timeline; + + const diff = scenario.finalBalance.minus(scenario.baseline); + + return ( +
+
+ + +
+ +

Cenário base

+
+
+ + setInitial(Number(e.target.value))} + /> +
+ + + setYears(Math.min(50, v))} + displayValue={`${years} anos`} + min={1} + max={40} + step={1} + /> +
+
+ + + +
+ +

E se...

+
+ +
+ + setExtraAtMonth(Number(e.target.value) || 0)} + placeholder="Ex: 12 (1 ano)" + /> +
+
+ + setExtraAmount(Number(e.target.value) || 0)} + placeholder="10000" + /> +
+
+ + setMonthlyWithdrawal(Number(e.target.value) || 0)} + placeholder="0" + /> +

+ Simula quanto você tiraria por mês (renda passiva). +

+
+
+
+
+ +
+
+ + +

+ Cenário base +

+

+ {formatBRL(scenario.baseline.toNumber())} +

+
+
+ + +

+ Com mudanças +

+

+ {formatBRL(scenario.finalBalance.toNumber())} +

+
+
+ + +

+ Diferença +

+

+ {diff.greaterThanOrEqualTo(0) ? "+" : ""} + {formatBRL(diff.toNumber())} +

+
+
+
+ + + +

Comparação mês a mês

+
+ + + + + + formatBRL(value)} + contentStyle={{ + backgroundColor: "hsl(var(--card))", + border: "2px solid hsl(var(--border))", + borderRadius: "12px", + }} + /> + + + + + +
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/portfolio/TransactionForm.tsx b/src/components/portfolio/TransactionForm.tsx new file mode 100644 index 0000000..879563a --- /dev/null +++ b/src/components/portfolio/TransactionForm.tsx @@ -0,0 +1,246 @@ +"use client"; + +import { useEffect, useState, useTransition } from "react"; +import { useForm } from "react-hook-form"; +import { Loader2, ArrowDownCircle, ArrowUpCircle } from "lucide-react"; + +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Button } from "@/components/ui/button"; +import { createClient } from "@/lib/supabase/client"; +import { createTransactionAction } from "@/app/transactions/actions"; +import type { AssetType } from "@/lib/supabase/types"; + +interface AssetOption { + id: string; + ticker: string; + name: string; + type: AssetType; + currency: string; +} + +const TRANSACTION_TYPES = [ + { value: "BUY", label: "Compra", icon: ArrowDownCircle, color: "text-emerald-600" }, + { value: "SELL", label: "Venda", icon: ArrowUpCircle, color: "text-red-600" }, + { value: "DIVIDEND", label: "Dividendo", icon: ArrowUpCircle, color: "text-blue-600" }, + { value: "INCOME", label: "Rendimento", icon: ArrowUpCircle, color: "text-blue-600" }, +] as const; + +export function TransactionForm() { + const [assets, setAssets] = useState([]); + const [loadingAssets, setLoadingAssets] = useState(true); + const [isPending, startTransition] = useTransition(); + const [serverError, setServerError] = useState(null); + const [fieldErrors, setFieldErrors] = useState>({}); + + const { register, handleSubmit, watch, setValue, formState: { errors } } = useForm({ + defaultValues: { + assetId: "", + type: "BUY" as const, + quantity: 0, + unitPrice: 0, + fees: 0, + currency: "BRL", + date: new Date().toISOString().substring(0, 10), + notes: "", + }, + }); + + useEffect(() => { + const supabase = createClient(); + (async () => { + const { data } = await supabase + .from("assets") + .select("id, ticker, name, type, currency") + .order("ticker"); + setAssets((data ?? []) as AssetOption[]); + setLoadingAssets(false); + })(); + }, []); + + const selectedAssetId = watch("assetId"); + useEffect(() => { + const a = assets.find((x) => x.id === selectedAssetId); + if (a) setValue("currency", a.currency); + }, [selectedAssetId, assets, setValue]); + + const selectedType = watch("type") as string; + const isFlow = selectedType === "BUY" || selectedType === "SELL"; + const isIncome = selectedType === "DIVIDEND" || selectedType === "INCOME"; + + function onSubmit(values: Record) { + setServerError(null); + setFieldErrors({}); + const formData = new FormData(); + for (const [k, v] of Object.entries(values)) { + formData.set(k, String(v ?? "")); + } + startTransition(async () => { + const result = await createTransactionAction({}, formData); + if (result?.error) setServerError(result.error); + if (result?.fieldErrors) setFieldErrors(result.fieldErrors); + }); + } + + return ( +
+
+ +
+ {TRANSACTION_TYPES.map((t) => { + const active = selectedType === t.value; + const Icon = t.icon; + return ( + + ); + })} +
+
+ +
+ + + {errors.assetId && ( +

{errors.assetId.message}

+ )} + {fieldErrors.assetId && ( +

{fieldErrors.assetId}

+ )} +
+ +
+
+ + +
+
+ + +
+ {isFlow && ( +
+ + +
+ )} +
+ + {isFlow && ( +
+
+ + +
+
+ + +
+
+ )} + + {isIncome && ( +
+ + setValue("quantity", Number(e.target.value))} + placeholder="150.00" + /> +

+ Para dividendos e rendimentos, informe o valor total recebido. +

+
+ )} + +
+ + +
+ + {serverError && ( +

{serverError}

+ )} + +
+ + +
+
+ ); +} \ No newline at end of file diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx new file mode 100644 index 0000000..fcaa39a --- /dev/null +++ b/src/components/ui/badge.tsx @@ -0,0 +1,31 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "@/lib/utils"; + +const badgeVariants = cva( + "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + { + variants: { + variant: { + default: "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", + secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", + outline: "text-foreground border-[hsl(var(--border))]", + success: "border-transparent bg-success text-success-foreground hover:bg-success/80", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return
; +} + +export { Badge, badgeVariants }; \ No newline at end of file diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index 44091d3..ccc3e83 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -1,66 +1,83 @@ import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; import { cn } from "@/lib/utils"; import { Loader2 } from "lucide-react"; -const Button = React.forwardRef< - HTMLButtonElement, - React.ButtonHTMLAttributes & { - variant?: - | "default" - | "destructive" - | "outline" - | "secondary" - | "ghost" - | "link" - | "success"; - size?: "default" | "sm" | "lg" | "icon"; - isLoading?: boolean; - } ->(({ className, variant = "default", size = "default", isLoading, children, disabled, ...props }, ref) => { - return ( - - ); -}); +export interface ButtonProps + extends React.ButtonHTMLAttributes { + variant?: + | "default" + | "destructive" + | "outline" + | "secondary" + | "ghost" + | "link" + | "success"; + size?: "default" | "sm" | "lg" | "icon"; + isLoading?: boolean; + asChild?: boolean; +} + +const Button = React.forwardRef( + ( + { + className, + variant = "default", + size = "default", + isLoading, + asChild, + children, + disabled, + ...props + }, + ref, + ) => { + const Comp = asChild ? Slot : "button"; + return ( + + {isLoading && !asChild ? ( + <> + + {children} + + ) : ( + children + )} + + ); + }, +); Button.displayName = "Button"; export { Button }; diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx index 112e9e3..4d8ef3c 100644 --- a/src/components/ui/select.tsx +++ b/src/components/ui/select.tsx @@ -35,7 +35,7 @@ function SelectTrigger({ data-slot="select-trigger" data-size={size} className={cn( - "border-input data-placeholder:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + "border-input data-placeholder:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive flex w-full items-center justify-between gap-2 rounded-md border border-[hsl(var(--border))] bg-background px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-colors outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-10 data-[size=sm]:h-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className )} {...props} @@ -51,8 +51,8 @@ function SelectTrigger({ function SelectContent({ className, children, - position = "item-aligned", - align = "center", + position = "popper", + align = "start", ...props }: React.ComponentProps) { return ( @@ -60,11 +60,15 @@ function SelectContent({ { + const supabase = await createClient(); + + const [{ data: assets }, { data: prices }] = await Promise.all([ + supabase.from("assets").select("*").eq("user_id", userId).order("ticker"), + supabase.from("price_cache").select("*"), + ]); + + const priceBySymbol = new Map(); + for (const p of (prices ?? []) as PriceRow[]) { + priceBySymbol.set(`${p.symbol}:${p.currency}`, p); + } + + return ((assets ?? []) as DbAsset[]).map((a) => ({ + ...a, + price: priceBySymbol.get(`${a.ticker}:${a.currency}`) ?? null, + })); +} + +export async function fetchTransactions(userId: string): Promise { + const supabase = await createClient(); + const { data } = await supabase + .from("transactions") + .select("*") + .eq("user_id", userId) + .order("date", { ascending: true }); + return (data ?? []) as DbTransaction[]; +} + +export async function fetchFixedIncomeDetails(assetIds: string[]) { + if (assetIds.length === 0) return []; + const supabase = await createClient(); + const { data } = await supabase + .from("fixed_income_details") + .select("*") + .in("asset_id", assetIds); + return (data ?? []) as Array<{ + asset_id: string; + kind: string; + indexer: string; + rate: number; + maturity_date: string | null; + issuer: string | null; + current_value: number | null; + }>; +} \ No newline at end of file diff --git a/src/core/domain/asset-classes.ts b/src/core/domain/asset-classes.ts new file mode 100644 index 0000000..b7292e7 --- /dev/null +++ b/src/core/domain/asset-classes.ts @@ -0,0 +1,63 @@ +import type { AssetType } from "@/lib/supabase/types"; + +export interface AssetClassMeta { + label: string; + labelPlural: string; + color: string; + bgColor: string; + textColor: string; +} + +export const ASSET_CLASS_META: Record = { + STOCK: { + label: "Ação", + labelPlural: "Ações", + color: "hsl(217, 91%, 60%)", + bgColor: "bg-blue-500/10", + textColor: "text-blue-600 dark:text-blue-400", + }, + ETF: { + label: "ETF", + labelPlural: "ETFs", + color: "hsl(262, 83%, 58%)", + bgColor: "bg-purple-500/10", + textColor: "text-purple-600 dark:text-purple-400", + }, + FII: { + label: "FII", + labelPlural: "FIIs", + color: "hsl(24, 95%, 53%)", + bgColor: "bg-orange-500/10", + textColor: "text-orange-600 dark:text-orange-400", + }, + CRYPTO: { + label: "Cripto", + labelPlural: "Criptomoedas", + color: "hsl(48, 96%, 53%)", + bgColor: "bg-yellow-500/10", + textColor: "text-yellow-600 dark:text-yellow-400", + }, + FIXED_INCOME: { + label: "Renda Fixa", + labelPlural: "Renda Fixa", + color: "hsl(142, 71%, 45%)", + bgColor: "bg-emerald-500/10", + textColor: "text-emerald-600 dark:text-emerald-400", + }, + CASH: { + label: "Caixa", + labelPlural: "Contas", + color: "hsl(220, 9%, 46%)", + bgColor: "bg-zinc-500/10", + textColor: "text-zinc-600 dark:text-zinc-400", + }, +}; + +export const TRANSACTION_TYPE_LABELS: Record = { + BUY: "Compra", + SELL: "Venda", + DIVIDEND: "Dividendo", + INCOME: "Rendimento", + SPLIT: "Desdobramento", + BONUS: "Bonificação", +}; \ No newline at end of file diff --git a/src/core/domain/portfolio-constants.ts b/src/core/domain/portfolio-constants.ts new file mode 100644 index 0000000..94a41e6 --- /dev/null +++ b/src/core/domain/portfolio-constants.ts @@ -0,0 +1,38 @@ +import type { AssetType } from "@/lib/supabase/types"; + +export const ASSET_TYPE_OPTIONS: Array<{ + value: AssetType; + label: string; + hint: string; +}> = [ + { value: "STOCK", label: "Ação", hint: "ON, PN, BDR, NYSE, NASDAQ" }, + { value: "ETF", label: "ETF", hint: "Fundos negociados em bolsa" }, + { value: "FII", label: "FII", hint: "Fundos imobiliários" }, + { value: "CRYPTO", label: "Cripto", hint: "BTC, ETH e outras" }, + { + value: "FIXED_INCOME", + label: "Renda Fixa", + hint: "CDB, LCI, LCA, Tesouro, Debêntures", + }, + { value: "CASH", label: "Caixa / Conta", hint: "Conta corrente, poupança" }, +]; + +export const FIXED_INCOME_KIND_OPTIONS = [ + { value: "CDB", label: "CDB" }, + { value: "LCI", label: "LCI" }, + { value: "LCA", label: "LCA" }, + { value: "TESOURO", label: "Tesouro Direto" }, + { value: "DEBENTURE", label: "Debênture" }, + { value: "CRI", label: "CRI" }, + { value: "CRA", label: "CRA" }, + { value: "LC", label: "Letra de Câmbio" }, + { value: "OTHER", label: "Outro" }, +] as const; + +export const FIXED_INCOME_INDEXER_OPTIONS = [ + { value: "PRE", label: "Prefixado", hint: "Taxa fixa anual" }, + { value: "CDI", label: "% do CDI", hint: "Ex: 110% do CDI" }, + { value: "IPCA", label: "IPCA +", hint: "Ex: IPCA + 6% a.a." }, + { value: "SELIC", label: "Selic", hint: "Ex: 100% Selic" }, + { value: "OTHER", label: "Outro" }, +] as const; \ No newline at end of file diff --git a/src/core/domain/portfolio.types.ts b/src/core/domain/portfolio.types.ts new file mode 100644 index 0000000..faa6f52 --- /dev/null +++ b/src/core/domain/portfolio.types.ts @@ -0,0 +1,79 @@ +import { Decimal } from "./financial-types"; +import type { AssetType, Currency, TransactionType } from "@/lib/supabase/types"; + +export type { + AssetType, + Currency, + TransactionType, + FixedIncomeKind, + FixedIncomeIndexer, + ExpectedRates, +} from "@/lib/supabase/types"; + +export interface Asset { + id: string; + ticker: string; + name: string; + type: AssetType; + currency: Currency; + exchange: string | null; + sector: string | null; + metadata: Record; +} + +export interface Transaction { + id: string; + assetId: string; + type: TransactionType; + quantity: number; + unitPrice: number; + total: number; + fees: number; + currency: Currency; + date: string; + notes: string | null; +} + +export interface FixedIncomeDetail { + assetId: string; + kind: + | "CDB" + | "LCI" + | "LCA" + | "TESOURO" + | "DEBENTURE" + | "CRI" + | "CRA" + | "LC" + | "OTHER"; + indexer: "PRE" | "CDI" | "IPCA" | "SELIC" | "OTHER"; + rate: number; + maturityDate: string | null; + issuer: string | null; + currentValue: number | null; +} + +export interface Position { + asset: Asset; + quantity: Decimal; + avgPrice: Decimal; + totalCost: Decimal; + realizedPnL: Decimal; + currentPrice: Decimal | null; + currentValue: Decimal | null; + unrealizedPnL: Decimal | null; + totalReturn: Decimal | null; + totalReturnPercent: Decimal | null; + dividendsReceived: Decimal; +} + +export interface PortfolioSummary { + totalCost: Decimal; + totalValue: Decimal; + totalUnrealized: Decimal; + totalRealized: Decimal; + totalDividends: Decimal; + totalReturn: Decimal; + totalReturnPercent: Decimal; + positionCount: number; +} \ No newline at end of file diff --git a/src/core/services/allocation.service.ts b/src/core/services/allocation.service.ts new file mode 100644 index 0000000..409962e --- /dev/null +++ b/src/core/services/allocation.service.ts @@ -0,0 +1,85 @@ +import { Decimal } from "../domain/financial-types"; +import type { Position } from "../domain/portfolio.types"; +import type { AssetType } from "@/lib/supabase/types"; + +export interface AllocationSlice { + type: AssetType; + value: Decimal; + percentage: Decimal; + positionCount: number; +} + +export interface AllocationByAsset { + assetId: string; + ticker: string; + value: Decimal; + percentage: Decimal; +} + +export interface AllocationResult { + total: Decimal; + byClass: AllocationSlice[]; + byAsset: AllocationByAsset[]; + concentrationWarnings: Array<{ ticker: string; percentage: Decimal }>; +} + +const DEFAULT_CONCENTRATION_THRESHOLD = 20; // % + +/** + * Calcula diversificação da carteira: + * - % por classe de ativo + * - % por ativo + * - alertas de concentração (ativo > threshold%) + */ +export function calculateAllocation( + positions: Position[], + concentrationThreshold = DEFAULT_CONCENTRATION_THRESHOLD, +): AllocationResult { + const valuedPositions = positions.filter( + (p) => p.currentValue !== null && !p.currentValue.isZero(), + ); + + const total = valuedPositions.reduce( + (acc, p) => acc.plus(p.currentValue!), + new Decimal(0), + ); + + const classMap = new Map(); + const assetList: AllocationByAsset[] = []; + + for (const p of valuedPositions) { + const type = p.asset.type; + const cur = classMap.get(type) ?? { value: new Decimal(0), count: 0 }; + cur.value = cur.value.plus(p.currentValue!); + cur.count += 1; + classMap.set(type, cur); + + assetList.push({ + assetId: p.asset.id, + ticker: p.asset.ticker, + value: p.currentValue!, + percentage: total.isZero() + ? new Decimal(0) + : p.currentValue!.dividedBy(total).times(100), + }); + } + + const byClass: AllocationSlice[] = Array.from(classMap.entries()) + .map(([type, { value, count }]) => ({ + type, + value, + count, + percentage: total.isZero() + ? new Decimal(0) + : value.dividedBy(total).times(100), + positionCount: count, + })) + .sort((a, b) => b.value.minus(a.value).toNumber()); + + const concentrationWarnings = assetList + .filter((a) => a.percentage.greaterThan(concentrationThreshold)) + .sort((a, b) => b.percentage.minus(a.percentage).toNumber()) + .map((a) => ({ ticker: a.ticker, percentage: a.percentage })); + + return { total, byClass, byAsset: assetList, concentrationWarnings }; +} \ No newline at end of file diff --git a/src/core/services/currency.service.ts b/src/core/services/currency.service.ts new file mode 100644 index 0000000..30acedd --- /dev/null +++ b/src/core/services/currency.service.ts @@ -0,0 +1,50 @@ +import "server-only"; +import { createClient } from "@/lib/supabase/server"; +import { Decimal } from "../domain/financial-types"; +import type { Currency } from "@/lib/supabase/types"; + +const FALLBACK_USD_BRL = 5.0; // fallback caso API falhe + +/** + * Cotação USD/BRL (1 USD = X BRL). + * Lê de price_cache (chave USDBRL), com fallback para 5.0. + */ +export async function getUsdBrlRate(): Promise { + const supabase = await createClient(); + const { data } = await supabase + .from("price_cache") + .select("price") + .eq("symbol", "USDBRL") + .eq("currency", "BRL") + .single(); + const rate = data?.price ? Number(data.price) : FALLBACK_USD_BRL; + return new Decimal(rate); +} + +/** + * Converte um valor entre moedas. + */ +export async function convertCurrency( + amount: Decimal | number, + from: Currency, + to: Currency, +): Promise { + const value = amount instanceof Decimal ? amount : new Decimal(amount); + if (from === to) return value; + if (from === "USD" && to === "BRL") { + const rate = await getUsdBrlRate(); + return value.times(rate); + } + if (from === "BRL" && to === "USD") { + const rate = await getUsdBrlRate(); + return value.dividedBy(rate); + } + return value; +} + +/** + * Converte um valor para BRL. + */ +export async function toBRL(amount: Decimal | number, from: Currency): Promise { + return convertCurrency(amount, from, "BRL"); +} \ No newline at end of file diff --git a/src/core/services/dividends.service.ts b/src/core/services/dividends.service.ts new file mode 100644 index 0000000..8a84239 --- /dev/null +++ b/src/core/services/dividends.service.ts @@ -0,0 +1,134 @@ +import { Decimal } from "../domain/financial-types"; +import type { Asset, Transaction } from "../domain/portfolio.types"; + +export interface DividendEntry { + assetId: string; + ticker: string; + date: string; + total: Decimal; + type: "DIVIDEND" | "INCOME"; +} + +export interface DividendByMonth { + yearMonth: string; // "2024-03" + label: string; // "Mar/2024" + total: Decimal; +} + +export interface DividendSummary { + totalReceived: Decimal; + byAsset: Array<{ + ticker: string; + assetId: string; + total: Decimal; + yieldOnCost: Decimal; // % sobre custo total + }>; + byMonth: DividendByMonth[]; + last12Months: Decimal; +} + +/** + * Consolida dividendos e rendimentos recebidos por ativo e por mês. + */ +export function summarizeDividends( + assets: Asset[], + transactions: Transaction[], + assetCosts: Record = {}, + referenceDate: Date = new Date(), +): DividendSummary { + const assetMap = new Map(assets.map((a) => [a.id, a])); + + const provEntries: DividendEntry[] = transactions + .filter((t) => t.type === "DIVIDEND" || t.type === "INCOME") + .map((t) => { + const asset = assetMap.get(t.assetId); + return { + assetId: t.assetId, + ticker: asset?.ticker ?? "?", + date: t.date, + total: new Decimal(t.total), + type: t.type as "DIVIDEND" | "INCOME", + }; + }); + + const totalReceived = provEntries.reduce( + (acc, e) => acc.plus(e.total), + new Decimal(0), + ); + + // Por ativo + const perAsset = new Map(); + for (const e of provEntries) { + perAsset.set(e.assetId, (perAsset.get(e.assetId) ?? new Decimal(0)).plus(e.total)); + } + + const byAsset = Array.from(perAsset.entries()).map(([assetId, total]) => { + const asset = assetMap.get(assetId); + const cost = assetCosts[assetId] + ? assetCosts[assetId] instanceof Decimal + ? assetCosts[assetId] + : new Decimal(assetCosts[assetId]) + : new Decimal(0); + const yieldOnCost = cost.isZero() + ? new Decimal(0) + : total.dividedBy(cost).times(100); + return { + ticker: asset?.ticker ?? "?", + assetId, + total, + yieldOnCost, + }; + }).sort((a, b) => b.total.minus(a.total).toNumber()); + + // Por mês + const perMonth = new Map(); + for (const e of provEntries) { + const ym = e.date.substring(0, 7); // YYYY-MM + perMonth.set(ym, (perMonth.get(ym) ?? new Decimal(0)).plus(e.total)); + } + + const byMonth: DividendByMonth[] = Array.from(perMonth.entries()) + .sort(([a], [c]) => a.localeCompare(c)) + .map(([ym, total]) => ({ + yearMonth: ym, + label: formatMonth(ym), + total, + })); + + // Últimos 12 meses + const cutoff = new Date(referenceDate); + cutoff.setMonth(cutoff.getMonth() - 12); + const cutoffStr = cutoff.toISOString().substring(0, 10); + + const last12Months = provEntries + .filter((e) => e.date >= cutoffStr) + .reduce((acc, e) => acc.plus(e.total), new Decimal(0)); + + return { + totalReceived, + byAsset, + byMonth, + last12Months, + }; +} + +const MONTHS_PT = [ + "Jan", + "Fev", + "Mar", + "Abr", + "Mai", + "Jun", + "Jul", + "Ago", + "Set", + "Out", + "Nov", + "Dez", +]; + +function formatMonth(ym: string): string { + const [y, m] = ym.split("-"); + const monthName = MONTHS_PT[Number(m) - 1] ?? m; + return `${monthName}/${y.slice(2)}`; +} \ No newline at end of file diff --git a/src/core/services/fixedIncome.service.ts b/src/core/services/fixedIncome.service.ts new file mode 100644 index 0000000..568ce57 --- /dev/null +++ b/src/core/services/fixedIncome.service.ts @@ -0,0 +1,77 @@ +import { Decimal } from "../domain/financial-types"; +import type { FixedIncomeDetail } from "../domain/portfolio.types"; + +/** + * Calcula o valor atual de um título de renda fixa. + * + * - CDI%: valor_final = principal * (1 + (CDI_anual * pct/100))^t + * simplificação: assumes capitalização anual do fator. + * - IPCA+: valor_final = principal * (1 + taxa) * (1 + IPCA)^t + * - PRE: valor_final = principal * (1 + taxa)^t + * - SELIC: equivalente a 100% do CDI + * - OTHER: aplica como prefixado + * + * @param detail detalhes do título + * @param principal valor investido (BRL) + * @param yearsElapsed anos desde a aplicação (fracionário permitido) + * @param annualCdi CDI anual em % (ex: 13.65). Necessário para indexadores CDI/SELIC. + * @param annualIpca IPCA acumulado anual em % (ex: 4.5). Necessário para IPCA+. + */ +export function calculateFixedIncomeValue( + detail: FixedIncomeDetail, + principal: Decimal | number, + yearsElapsed: Decimal | number, + annualCdi: Decimal | number = 13.65, + annualIpca: Decimal | number = 4.5, +): Decimal { + const P = decimal(principal); + const t = decimal(yearsElapsed); + + switch (detail.indexer) { + case "CDI": { + const cdi = decimal(annualCdi).div(100); + const pct = new Decimal(detail.rate).div(100); + const effectiveRate = cdi.times(pct); + return P.times(new Decimal(1).plus(effectiveRate).pow(t)); + } + case "SELIC": { + const selic = decimal(annualCdi).div(100); // SELIC ~= CDI neste cálculo + const pct = new Decimal(detail.rate).div(100); + const effectiveRate = selic.times(pct); + return P.times(new Decimal(1).plus(effectiveRate).pow(t)); + } + case "IPCA": { + const ipca = decimal(annualIpca).div(100); + const fixed = new Decimal(detail.rate).div(100); + return P.times(new Decimal(1).plus(fixed)).times( + new Decimal(1).plus(ipca).pow(t), + ); + } + case "PRE": { + const fixed = new Decimal(detail.rate).div(100); + return P.times(new Decimal(1).plus(fixed).pow(t)); + } + case "OTHER": + default: { + const fixed = new Decimal(detail.rate).div(100); + return P.times(new Decimal(1).plus(fixed).pow(t)); + } + } +} + +/** + * Dias até o vencimento (negativo se vencido). + */ +export function daysToMaturity( + maturityDate: string | null, + referenceDate: Date = new Date(), +): number | null { + if (!maturityDate) return null; + const maturity = new Date(maturityDate + "T00:00:00"); + const diff = maturity.getTime() - referenceDate.getTime(); + return Math.round(diff / (1000 * 60 * 60 * 24)); +} + +function decimal(v: Decimal | number): Decimal { + return v instanceof Decimal ? v : new Decimal(v); +} \ No newline at end of file diff --git a/src/core/services/portfolio-aggregator.service.ts b/src/core/services/portfolio-aggregator.service.ts new file mode 100644 index 0000000..c6bd3cb --- /dev/null +++ b/src/core/services/portfolio-aggregator.service.ts @@ -0,0 +1,62 @@ +import { Decimal } from "../domain/financial-types"; +import { toBRL } from "./currency.service"; +import type { Position, PortfolioSummary } from "../domain/portfolio.types"; +import type { Currency } from "@/lib/supabase/types"; + +/** + * Soma o valor em BRL de uma lista de posições, + * convertendo USD → BRL quando preciso. + * + * Posições sem currentValue são ignoradas. + */ +export async function totalValueInBRL(positions: Position[]): Promise { + let total = new Decimal(0); + for (const p of positions) { + if (p.currentValue === null) continue; + const brl = await toBRL(p.currentValue, p.asset.currency as Currency); + total = total.plus(brl); + } + return total; +} + +/** + * Resumo agregando em BRL — usado no dashboard. + */ +export async function summarizeInBRL( + positions: Position[], +): Promise<{ + totalCostBRL: Decimal; + totalValueBRL: Decimal; + totalReturnBRL: Decimal; + totalReturnPercent: Decimal; +}> { + let totalCostBRL = new Decimal(0); + let totalValueBRL = new Decimal(0); + let totalReturnBRL = new Decimal(0); + + for (const p of positions) { + const cost = await toBRL(p.totalCost, p.asset.currency as Currency); + totalCostBRL = totalCostBRL.plus(cost); + if (p.currentValue !== null) { + const value = await toBRL(p.currentValue, p.asset.currency as Currency); + totalValueBRL = totalValueBRL.plus(value); + } + if (p.totalReturn !== null) { + const ret = await toBRL(p.totalReturn, p.asset.currency as Currency); + totalReturnBRL = totalReturnBRL.plus(ret); + } + } + + const totalReturnPercent = totalCostBRL.isZero() + ? new Decimal(0) + : totalReturnBRL.dividedBy(totalCostBRL).times(100); + + return { + totalCostBRL, + totalValueBRL, + totalReturnBRL, + totalReturnPercent, + }; +} + +export type { PortfolioSummary }; \ No newline at end of file diff --git a/src/core/services/portfolio.service.ts b/src/core/services/portfolio.service.ts new file mode 100644 index 0000000..0a7ce7c --- /dev/null +++ b/src/core/services/portfolio.service.ts @@ -0,0 +1,173 @@ +import { Decimal } from "../domain/financial-types"; +import type { + Asset, + Position, + PortfolioSummary, + Transaction, +} from "../domain/portfolio.types"; + +const sortByDate = (a: Transaction, b: Transaction) => + a.date.localeCompare(b.date); + +/** + * Calcula posição consolidada de um ativo a partir de suas transações. + * + * Regras: + * - BUY: soma na quantidade e custo total + * - SELL: realiza lucro/prejuízo com base no preço médio ATUAL + * - DIVIDEND / INCOME: acumula em dividendos recebidos (não altera posição) + * - SPLIT / BONUS: tratados como informação (não implementado aqui — aplicar via metadata) + */ +export function calculatePosition( + asset: Asset, + transactions: Transaction[], + currentPrice: Decimal | number | null = null, +): Position { + const sorted = [...transactions].sort(sortByDate); + + let quantity = new Decimal(0); + let totalCost = new Decimal(0); + let realizedPnL = new Decimal(0); + let dividendsReceived = new Decimal(0); + + for (const tx of sorted) { + const qty = new Decimal(tx.quantity); + const total = new Decimal(tx.total); + const unitPrice = new Decimal(tx.unitPrice); + + switch (tx.type) { + case "BUY": { + quantity = quantity.plus(qty); + totalCost = totalCost.plus(total); + break; + } + case "SELL": { + if (quantity.isZero()) break; // nada para vender + const sellQty = Decimal.min(qty, quantity); + const avgPrice = totalCost.dividedBy(quantity); + realizedPnL = realizedPnL.plus( + unitPrice.minus(avgPrice).times(sellQty), + ); + const costReduction = avgPrice.times(sellQty); + totalCost = totalCost.minus(costReduction); + quantity = quantity.minus(sellQty); + break; + } + case "DIVIDEND": + case "INCOME": { + dividendsReceived = dividendsReceived.plus(total); + break; + } + case "SPLIT": + case "BONUS": { + // Ignorado no cálculo padrão. Aplicação real requer tratamento especial + // baseado em metadata (ratio). Será suportado em versão futura. + break; + } + } + } + + const avgPrice = quantity.isZero() + ? new Decimal(0) + : totalCost.dividedBy(quantity); + + const priceDecimal = + currentPrice === null || currentPrice === undefined + ? null + : currentPrice instanceof Decimal + ? currentPrice + : new Decimal(currentPrice); + + const currentValue = + priceDecimal && !quantity.isZero() ? quantity.times(priceDecimal) : null; + const unrealizedPnL = + currentValue && !quantity.isZero() + ? currentValue.minus(totalCost) + : null; + const totalReturn = unrealizedPnL + ? unrealizedPnL.plus(realizedPnL).plus(dividendsReceived) + : realizedPnL.plus(dividendsReceived); + + const totalReturnPercent = + !totalCost.isZero() && totalReturn + ? totalReturn.dividedBy(totalCost).times(100) + : null; + + return { + asset, + quantity, + avgPrice, + totalCost, + realizedPnL, + currentPrice: priceDecimal, + currentValue, + unrealizedPnL, + totalReturn, + totalReturnPercent, + dividendsReceived, + }; +} + +/** + * Agrupa transações por assetId e calcula a posição de cada ativo. + */ +export function calculatePortfolio( + assets: Asset[], + transactions: Transaction[], + currentPrices: Record = {}, +): Position[] { + const txByAsset = new Map(); + for (const tx of transactions) { + const list = txByAsset.get(tx.assetId) ?? []; + list.push(tx); + txByAsset.set(tx.assetId, list); + } + + return assets.map((asset) => + calculatePosition( + asset, + txByAsset.get(asset.id) ?? [], + currentPrices[asset.id] ?? null, + ), + ); +} + +/** + * Resume a carteira agregando todas as posições. + * Considera apenas posições com currentValue definido para valor total. + */ +export function summarizePortfolio(positions: Position[]): PortfolioSummary { + let totalCost = new Decimal(0); + let totalValue = new Decimal(0); + let totalUnrealized = new Decimal(0); + let totalRealized = new Decimal(0); + let totalDividends = new Decimal(0); + + for (const p of positions) { + totalCost = totalCost.plus(p.totalCost); + totalRealized = totalRealized.plus(p.realizedPnL); + totalDividends = totalDividends.plus(p.dividendsReceived); + if (p.currentValue !== null) { + totalValue = totalValue.plus(p.currentValue); + totalUnrealized = totalUnrealized.plus( + p.unrealizedPnL ?? new Decimal(0), + ); + } + } + + const totalReturn = totalUnrealized.plus(totalRealized).plus(totalDividends); + const totalReturnPercent = totalCost.isZero() + ? new Decimal(0) + : totalReturn.dividedBy(totalCost).times(100); + + return { + totalCost, + totalValue, + totalUnrealized, + totalRealized, + totalDividends, + totalReturn, + totalReturnPercent, + positionCount: positions.length, + }; +} \ No newline at end of file diff --git a/src/core/services/priceFetcher.service.ts b/src/core/services/priceFetcher.service.ts new file mode 100644 index 0000000..cc7477d --- /dev/null +++ b/src/core/services/priceFetcher.service.ts @@ -0,0 +1,195 @@ +import "server-only"; +import { createClient } from "@/lib/supabase/server"; +import type { AssetType, Currency } from "@/lib/supabase/types"; + +export interface PriceFetchResult { + symbol: string; + currency: Currency; + price: number; + source: string; +} + +const CACHE_TTL_MS = 1000 * 60 * 15; // 15 min + +// ============================================================================= +// CoinGecko (cripto) +// ============================================================================= +const COINGECKO_IDS: Record = { + BTC: "bitcoin", + ETH: "ethereum", + USDT: "tether", + BNB: "binancecoin", + SOL: "solana", + XRP: "ripple", + ADA: "cardano", + DOGE: "dogecoin", + AVAX: "avalanche-2", + DOT: "polkadot", + MATIC: "matic-network", + LINK: "chainlink", + UNI: "uniswap", + LTC: "litecoin", +}; + +async function fetchCrypto(symbol: string): Promise { + const id = COINGECKO_IDS[symbol.toUpperCase()]; + if (!id) return null; + try { + const url = `https://api.coingecko.com/api/v3/simple/price?ids=${id}&vs_currencies=brl`; + const res = await fetch(url, { next: { revalidate: 300 } }); + if (!res.ok) return null; + const data = await res.json(); + const price = data?.[id]?.brl; + return typeof price === "number" ? price : null; + } catch { + return null; + } +} + +// ============================================================================= +// Brapi (ações/FIIs/ETFs BR em BRL) +// ============================================================================= +async function fetchBrapi(ticker: string): Promise { + try { + const url = `https://brapi.dev/api/quote/${encodeURIComponent(ticker)}?range=1d`; + const res = await fetch(url, { next: { revalidate: 300 } }); + if (!res.ok) return null; + const data = await res.json(); + const price = data?.results?.[0]?.regularMarketPrice; + return typeof price === "number" ? price : null; + } catch { + return null; + } +} + +// ============================================================================= +// Yahoo Finance (US stocks/ETFs em USD) — endpoint unofficial +// ============================================================================= +async function fetchYahoo(ticker: string): Promise { + try { + const url = `https://query1.finance.yahoo.com/v8/finance/chart/${encodeURIComponent(ticker)}?interval=1d&range=1d`; + const res = await fetch(url, { + next: { revalidate: 300 }, + headers: { + "User-Agent": "Mozilla/5.0 FinForge/1.0", + }, + }); + if (!res.ok) return null; + const data = await res.json(); + const meta = data?.chart?.result?.[0]?.meta; + const price = meta?.regularMarketPrice; + return typeof price === "number" ? price : null; + } catch { + return null; + } +} + +// ============================================================================= +// USD/BRL (AwesomeAPI) +// ============================================================================= +export async function fetchUsdBrl(): Promise { + try { + const res = await fetch( + "https://economia.awesomeapi.com.br/json/last/USD-BRL", + { next: { revalidate: 3600 } }, + ); + if (!res.ok) return null; + const data = await res.json(); + const bid = data?.USDBRL?.bid; + const rate = typeof bid === "string" ? parseFloat(bid) : bid; + return typeof rate === "number" ? rate : null; + } catch { + return null; + } +} + +// ============================================================================= +// Roteador +// ============================================================================= +export async function fetchPrice( + type: AssetType, + ticker: string, + currency: Currency, +): Promise { + let price: number | null = null; + let source = "unknown"; + + switch (type) { + case "CRYPTO": + price = await fetchCrypto(ticker); + source = "coingecko"; + break; + case "STOCK": + case "ETF": + case "CASH": + if (currency === "USD") { + price = await fetchYahoo(ticker); + source = "yahoo"; + } else { + price = await fetchBrapi(ticker); + source = "brapi"; + } + break; + case "FII": + price = await fetchBrapi(ticker); + source = "brapi"; + break; + case "FIXED_INCOME": + // Não tem cotação automática + return null; + } + + if (price === null || price === undefined || price <= 0) return null; + return { symbol: ticker.toUpperCase(), currency, price, source }; +} + +// ============================================================================= +// Cache layer +// ============================================================================= +export async function getCachedPrice( + symbol: string, + currency: Currency, +): Promise { + const supabase = await createClient(); + const { data } = await supabase + .from("price_cache") + .select("price, updated_at") + .eq("symbol", symbol.toUpperCase()) + .eq("currency", currency) + .single(); + if (!data) return null; + const updatedAt = new Date(data.updated_at).getTime(); + if (Date.now() - updatedAt > CACHE_TTL_MS) return null; + return Number(data.price); +} + +export async function saveCachedPrice(result: PriceFetchResult) { + const supabase = await createClient(); + await supabase.from("price_cache").upsert( + { + symbol: result.symbol, + currency: result.currency, + price: result.price, + source: result.source, + updated_at: new Date().toISOString(), + }, + { onConflict: "symbol,currency" }, + ); +} + +export async function getOrFetchPrice( + type: AssetType, + ticker: string, + currency: Currency, +): Promise { + if (type === "FIXED_INCOME" || type === "CASH") return null; + + const cached = await getCachedPrice(ticker, currency); + if (cached !== null) return cached; + + const result = await fetchPrice(type, ticker, currency); + if (!result) return null; + + await saveCachedPrice(result); + return result.price; +} \ No newline at end of file diff --git a/src/core/services/projection.service.ts b/src/core/services/projection.service.ts new file mode 100644 index 0000000..25ee326 --- /dev/null +++ b/src/core/services/projection.service.ts @@ -0,0 +1,114 @@ +import { Decimal } from "../domain/financial-types"; + +export interface ProjectionParams { + initialAmount: Decimal | number; + monthlyContribution: Decimal | number; + annualRate: Decimal | number; // % + years: number; + inflationRate?: Decimal | number; // % (opcional, para poder de compra) + monthlyWithdrawal?: Decimal | number; // renda passiva (negativo se for retirada) +} + +export interface ProjectionPoint { + month: number; + year: number; + yearLabel: string; + balance: Decimal; + totalContributed: Decimal; + totalInterest: Decimal; + realBalance: Decimal | null; +} + +export interface ProjectionResult { + params: ProjectionParams; + finalBalance: Decimal; + totalContributed: Decimal; + totalInterest: Decimal; + realFinalBalance: Decimal | null; + timeline: ProjectionPoint[]; +} + +/** + * Projeta o patrimônio mês a mês com aportes e/ou retiradas. + * Fórmula: saldo = (saldo_anterior + aporte - retirada) * (1 + taxa_mensal) + * Onde taxa_mensal = (1 + taxa_anual)^(1/12) - 1 (capitalização composta mensal). + */ +export function projectPortfolio(params: ProjectionParams): ProjectionResult { + const initial = decimal(params.initialAmount); + const monthlyContribution = decimal(params.monthlyContribution); + const annualRate = decimal(params.annualRate).div(100); + const years = params.years; + const inflationRate = params.inflationRate + ? decimal(params.inflationRate).div(100) + : null; + const monthlyWithdrawal = params.monthlyWithdrawal + ? decimal(params.monthlyWithdrawal) + : new Decimal(0); + + // taxa mensal equivalente a anual + const monthlyRate = new Decimal(1).plus(annualRate).pow(new Decimal(1).div(12)).minus(new Decimal(1)); + + const months = years * 12; + let balance = initial; + let totalContributed = initial; + + const timeline: ProjectionPoint[] = [ + monthSnapshot(0, balance, totalContributed, inflationRate, 0), + ]; + + for (let m = 1; m <= months; m++) { + balance = balance.plus(monthlyContribution).minus(monthlyWithdrawal); + totalContributed = totalContributed.plus(monthlyContribution).minus(monthlyWithdrawal); + balance = balance.times(new Decimal(1).plus(monthlyRate)); + + if (m % 12 === 0) { + timeline.push( + monthSnapshot(m, balance, totalContributed, inflationRate, m / 12), + ); + } + } + + const finalBalance = balance; + const totalInterest = finalBalance.minus(totalContributed); + const realFinalBalance = inflationRate + ? finalBalance.dividedBy( + new Decimal(1).plus(inflationRate).pow(years), + ) + : null; + + return { + params, + finalBalance, + totalContributed, + totalInterest, + realFinalBalance, + timeline, + }; +} + +function monthSnapshot( + month: number, + balance: Decimal, + contributed: Decimal, + inflationRate: Decimal | null, + year: number, +): ProjectionPoint { + const realBalance = + inflationRate && year > 0 + ? balance.dividedBy(new Decimal(1).plus(inflationRate).pow(year)) + : null; + + return { + month, + year, + yearLabel: year === 0 ? "Hoje" : `+${year}a`, + balance, + totalContributed: contributed, + totalInterest: balance.minus(contributed), + realBalance, + }; +} + +function decimal(v: Decimal | number): Decimal { + return v instanceof Decimal ? v : new Decimal(v); +} \ No newline at end of file diff --git a/src/core/utils/currency-format.ts b/src/core/utils/currency-format.ts new file mode 100644 index 0000000..5ba2ae9 --- /dev/null +++ b/src/core/utils/currency-format.ts @@ -0,0 +1,36 @@ +import "server-only"; +import { Decimal } from "decimal.js"; + +/** + * Helpers de formatação para múltiplas moedas. + */ + +export function formatCurrency(value: number | Decimal, currency: Currency): string { + const num = value instanceof Decimal ? value.toNumber() : value; + return new Intl.NumberFormat("pt-BR", { + style: "currency", + currency, + minimumFractionDigits: 2, + }).format(num); +} + +export type Currency = "BRL" | "USD"; + +export async function formatBRL(value: number | Decimal): Promise { + return formatCurrency(value, "BRL"); +} + +/** + * Formata um valor mostrando também a conversão para BRL quando aplicável. + * Ex: "US$ 100.00 (≈ R$ 500.00)" + */ +export function formatDualCurrency( + value: number | Decimal, + currency: Currency, + brlEquivalent: number | Decimal | null, +): string { + const main = formatCurrency(value, currency); + if (currency === "BRL" || brlEquivalent === null) return main; + const eq = formatCurrency(brlEquivalent, "BRL"); + return `${main} (≈ ${eq})`; +} \ No newline at end of file diff --git a/src/hooks/useAuth.ts b/src/hooks/useAuth.ts new file mode 100644 index 0000000..3c23461 --- /dev/null +++ b/src/hooks/useAuth.ts @@ -0,0 +1,35 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { createClient } from "@/lib/supabase/client"; +import type { User } from "@supabase/supabase-js"; + +export function useAuth() { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + const supabase = createClient(); + + useEffect(() => { + let mounted = true; + + supabase.auth.getUser().then(({ data }) => { + if (mounted) { + setUser(data.user); + setLoading(false); + } + }); + + const { + data: { subscription }, + } = supabase.auth.onAuthStateChange((_event, session) => { + setUser(session?.user ?? null); + }); + + return () => { + mounted = false; + subscription.unsubscribe(); + }; + }, [supabase]); + + return { user, loading }; +} \ No newline at end of file diff --git a/src/lib/supabase/client.ts b/src/lib/supabase/client.ts new file mode 100644 index 0000000..501597d --- /dev/null +++ b/src/lib/supabase/client.ts @@ -0,0 +1,9 @@ +import { createBrowserClient } from "@supabase/ssr"; +import type { Database } from "./types"; + +export function createClient() { + return createBrowserClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + ); +} \ No newline at end of file diff --git a/src/lib/supabase/middleware.ts b/src/lib/supabase/middleware.ts new file mode 100644 index 0000000..ba33ee1 --- /dev/null +++ b/src/lib/supabase/middleware.ts @@ -0,0 +1,33 @@ +import { createServerClient } from "@supabase/ssr"; +import { NextResponse, type NextRequest } from "next/server"; +import type { Database } from "./types"; + +export async function updateSession(request: NextRequest) { + let response = NextResponse.next({ request }); + + const supabase = createServerClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + { + cookies: { + getAll() { + return request.cookies.getAll(); + }, + setAll(cookiesToSet) { + cookiesToSet.forEach(({ name, value }) => + request.cookies.set(name, value), + ); + response = NextResponse.next({ request }); + cookiesToSet.forEach(({ name, value, options }) => + response.cookies.set(name, value, options), + ); + }, + }, + }, + ); + + // CRÍTICO: refresh da sessão do usuário — não remover. + await supabase.auth.getUser(); + + return response; +} \ No newline at end of file diff --git a/src/lib/supabase/server.ts b/src/lib/supabase/server.ts new file mode 100644 index 0000000..a614fb0 --- /dev/null +++ b/src/lib/supabase/server.ts @@ -0,0 +1,28 @@ +import { createServerClient } from "@supabase/ssr"; +import { cookies } from "next/headers"; +import type { Database } from "./types"; + +export async function createClient() { + const cookieStore = await cookies(); + + return createServerClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + { + cookies: { + getAll() { + return cookieStore.getAll(); + }, + setAll(cookiesToSet) { + try { + cookiesToSet.forEach(({ name, value, options }) => + cookieStore.set(name, value, options), + ); + } catch { + // Server Component não pode setar cookies — tratado pelo middleware. + } + }, + }, + }, + ); +} \ No newline at end of file diff --git a/src/lib/supabase/types.ts b/src/lib/supabase/types.ts new file mode 100644 index 0000000..7919f6e --- /dev/null +++ b/src/lib/supabase/types.ts @@ -0,0 +1,197 @@ +/** + * Tipos manuais do schema Supabase. + * + * Após rodar `npx supabase gen types typescript --project-id --schema public > src/lib/supabase/types.ts` + * este arquivo será regenerado automaticamente. Mantemos uma versão manual + * sincronizada com `supabase/migrations/` para garantir type-safety desde o início. + */ + +export type Json = + | string + | number + | boolean + | null + | { [key: string]: Json | undefined } + | Json[]; + +export type AssetType = + | "STOCK" + | "ETF" + | "FII" + | "CRYPTO" + | "FIXED_INCOME" + | "CASH"; + +export type Currency = "BRL" | "USD"; + +export type TransactionType = + | "BUY" + | "SELL" + | "DIVIDEND" + | "INCOME" + | "SPLIT" + | "BONUS"; + +export type FixedIncomeKind = + | "CDB" + | "LCI" + | "LCA" + | "TESOURO" + | "DEBENTURE" + | "CRI" + | "CRA" + | "LC" + | "OTHER"; + +export type FixedIncomeIndexer = "PRE" | "CDI" | "IPCA" | "SELIC" | "OTHER"; + +export interface ExpectedRates { + STOCK: number; + ETF: number; + FII: number; + CRYPTO: number; + FIXED_INCOME: number; + CASH: number; +} + +export interface Database { + public: { + Tables: { + profiles: { + Row: { + id: string; + display_name: string | null; + base_currency: Currency; + expected_rates: ExpectedRates; + ipca_annual: number; + created_at: string; + updated_at: string; + }; + Insert: { + id: string; + display_name?: string | null; + base_currency?: Currency; + expected_rates?: ExpectedRates; + ipca_annual?: number; + created_at?: string; + updated_at?: string; + }; + Update: Partial; + Relationships: []; + }; + assets: { + Row: { + id: string; + user_id: string; + ticker: string; + name: string; + type: AssetType; + currency: Currency; + exchange: string | null; + sector: string | null; + metadata: Json; + created_at: string; + updated_at: string; + }; + Insert: { + id?: string; + user_id: string; + ticker: string; + name: string; + type: AssetType; + currency?: Currency; + exchange?: string | null; + sector?: string | null; + metadata?: Json; + created_at?: string; + updated_at?: string; + }; + Update: Partial; + Relationships: []; + }; + fixed_income_details: { + Row: { + asset_id: string; + kind: FixedIncomeKind; + indexer: FixedIncomeIndexer; + rate: number; + maturity_date: string | null; + issuer: string | null; + current_value: number | null; + }; + Insert: { + asset_id: string; + kind: FixedIncomeKind; + indexer: FixedIncomeIndexer; + rate: number; + maturity_date?: string | null; + issuer?: string | null; + current_value?: number | null; + }; + Update: Partial< + Database["public"]["Tables"]["fixed_income_details"]["Insert"] + >; + Relationships: []; + }; + transactions: { + Row: { + id: string; + user_id: string; + asset_id: string; + type: TransactionType; + quantity: number; + unit_price: number; + total: number; + fees: number; + currency: Currency; + date: string; + notes: string | null; + created_at: string; + }; + Insert: { + id?: string; + user_id: string; + asset_id: string; + type: TransactionType; + quantity?: number; + unit_price?: number; + total?: number; + fees?: number; + currency?: Currency; + date: string; + notes?: string | null; + created_at?: string; + }; + Update: Partial; + Relationships: []; + }; + price_cache: { + Row: { + symbol: string; + currency: Currency; + price: number; + source: string; + updated_at: string; + }; + Insert: { + symbol: string; + currency: Currency; + price: number; + source: string; + updated_at?: string; + }; + Update: Partial; + Relationships: []; + }; + }; + Views: Record; + Functions: Record; + Enums: { + asset_type: AssetType; + currency: Currency; + transaction_type: TransactionType; + fixed_income_kind: FixedIncomeKind; + fixed_income_indexer: FixedIncomeIndexer; + }; + }; +} \ No newline at end of file diff --git a/src/test/allocation.service.test.ts b/src/test/allocation.service.test.ts new file mode 100644 index 0000000..86fed5b --- /dev/null +++ b/src/test/allocation.service.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect } from "vitest"; +import Decimal from "decimal.js"; +import { calculatePortfolio } from "@/core/services/portfolio.service"; +import { calculateAllocation } from "@/core/services/allocation.service"; +import type { Asset } from "@/core/domain/portfolio.types"; + +const stockA: Asset = { id: "1", ticker: "PETR4", name: "", type: "STOCK", currency: "BRL", exchange: null, sector: null, metadata: {} }; +const stockB: Asset = { id: "2", ticker: "VALE3", name: "", type: "STOCK", currency: "BRL", exchange: null, sector: null, metadata: {} }; +const fii: Asset = { id: "3", ticker: "MXRF11", name: "", type: "FII", currency: "BRL", exchange: null, sector: null, metadata: {} }; + +describe("calculateAllocation", () => { + it("calcula % por classe e por ativo", () => { + const positions = calculatePortfolio( + [stockA, stockB, fii], + [ + { id: "t1", assetId: "1", type: "BUY", quantity: 100, unitPrice: 30, total: 3000, fees: 0, currency: "BRL", date: "2024-01-01", notes: null }, + { id: "t2", assetId: "2", type: "BUY", quantity: 50, unitPrice: 70, total: 3500, fees: 0, currency: "BRL", date: "2024-01-01", notes: null }, + { id: "t3", assetId: "3", type: "BUY", quantity: 100, unitPrice: 10, total: 1000, fees: 0, currency: "BRL", date: "2024-01-01", notes: null }, + ], + { + "1": new Decimal(40), // PETR4: 4000 + "2": new Decimal(60), // VALE3: 3000 + "3": new Decimal(11), // MXRF11: 1100 + }, + ); + const alloc = calculateAllocation(positions); + expect(alloc.total.toNumber()).toBe(8100); + // STOCK: PETR4+VALE3 = 7000 / 8100 = 86.42% + const stock = alloc.byClass.find((c) => c.type === "STOCK")!; + expect(stock.value.toNumber()).toBe(7000); + expect(stock.percentage.toNumber()).toBeCloseTo(86.42, 1); + expect(stock.positionCount).toBe(2); + + const fiiSlice = alloc.byClass.find((c) => c.type === "FII")!; + expect(fiiSlice.value.toNumber()).toBe(1100); + expect(fiiSlice.percentage.toNumber()).toBeCloseTo(13.58, 1); + }); + + it("emite alerta de concentração quando ativo > 20%", () => { + const positions = calculatePortfolio( + [stockA, fii], + [ + { id: "t1", assetId: "1", type: "BUY", quantity: 100, unitPrice: 30, total: 3000, fees: 0, currency: "BRL", date: "2024-01-01", notes: null }, + { id: "t2", assetId: "3", type: "BUY", quantity: 10, unitPrice: 100, total: 1000, fees: 0, currency: "BRL", date: "2024-01-01", notes: null }, + ], + { + "1": new Decimal(30), // 3000 (27.3%) + "3": new Decimal(800), // 8000 (72.7%) + }, + ); + const alloc = calculateAllocation(positions, 20); + // Ambos > 20%, então 2 warnings ordenados por % desc + expect(alloc.concentrationWarnings).toHaveLength(2); + expect(alloc.concentrationWarnings[0].ticker).toBe("MXRF11"); + expect(alloc.concentrationWarnings[1].ticker).toBe("PETR4"); + expect(alloc.concentrationWarnings[0].percentage.toNumber()).toBeGreaterThan(70); + }); + + it("carteira vazia retorna zeros", () => { + const alloc = calculateAllocation([]); + expect(alloc.total.toNumber()).toBe(0); + expect(alloc.byClass).toHaveLength(0); + expect(alloc.concentrationWarnings).toHaveLength(0); + }); +}); \ No newline at end of file diff --git a/src/test/dividends.service.test.ts b/src/test/dividends.service.test.ts new file mode 100644 index 0000000..54bcc02 --- /dev/null +++ b/src/test/dividends.service.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect } from "vitest"; +import Decimal from "decimal.js"; +import { summarizeDividends } from "@/core/services/dividends.service"; +import type { Asset, Transaction } from "@/core/domain/portfolio.types"; + +const petr: Asset = { id: "a1", ticker: "PETR4", name: "Petrobras", type: "STOCK", currency: "BRL", exchange: null, sector: null, metadata: {} }; +const vale: Asset = { id: "a2", ticker: "VALE3", name: "Vale", type: "STOCK", currency: "BRL", exchange: null, sector: null, metadata: {} }; + +function tx(p: Partial): Transaction { + return { + id: p.id ?? Math.random().toString(), + assetId: p.assetId ?? "a1", + type: p.type ?? "DIVIDEND", + quantity: p.quantity ?? 0, + unitPrice: p.unitPrice ?? 0, + total: p.total ?? 0, + fees: p.fees ?? 0, + currency: p.currency ?? "BRL", + date: p.date ?? "2024-01-01", + notes: p.notes ?? null, + }; +} + +describe("summarizeDividends", () => { + it("soma dividendos por ativo", () => { + const result = summarizeDividends( + [petr, vale], + [ + tx({ assetId: "a1", date: "2024-03-15", total: 100 }), + tx({ assetId: "a1", date: "2024-09-15", total: 150 }), + tx({ assetId: "a2", date: "2024-06-10", total: 80 }), + ], + ); + expect(result.totalReceived.toNumber()).toBe(330); + const petrEntry = result.byAsset.find((b) => b.ticker === "PETR4")!; + expect(petrEntry.total.toNumber()).toBe(250); + const valeEntry = result.byAsset.find((b) => b.ticker === "VALE3")!; + expect(valeEntry.total.toNumber()).toBe(80); + }); + + it("calcula yield on cost", () => { + const result = summarizeDividends( + [petr], + [tx({ assetId: "a1", total: 100 })], + { a1: new Decimal(2000) }, + ); + expect(result.byAsset[0].yieldOnCost.toNumber()).toBe(5); // 100/2000*100 + }); + + it("yield on cost = 0 quando custo é 0", () => { + const result = summarizeDividends([petr], [tx({ assetId: "a1", total: 100 })]); + expect(result.byAsset[0].yieldOnCost.toNumber()).toBe(0); + }); + + it("agrupa por mês", () => { + const result = summarizeDividends( + [petr], + [ + tx({ id: "1", date: "2024-03-15", total: 100 }), + tx({ id: "2", date: "2024-03-30", total: 50 }), + tx({ id: "3", date: "2024-06-10", total: 80 }), + ], + ); + expect(result.byMonth).toHaveLength(2); + expect(result.byMonth[0].total.toNumber()).toBe(150); // Mar + expect(result.byMonth[1].total.toNumber()).toBe(80); // Jun + }); + + it("sem dividendos retorna totais zerados", () => { + const result = summarizeDividends([petr], []); + expect(result.totalReceived.toNumber()).toBe(0); + expect(result.byMonth).toHaveLength(0); + expect(result.byAsset).toHaveLength(0); + }); +}); \ No newline at end of file diff --git a/src/test/fixedIncome.service.test.ts b/src/test/fixedIncome.service.test.ts new file mode 100644 index 0000000..84e1fba --- /dev/null +++ b/src/test/fixedIncome.service.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect } from "vitest"; +import Decimal from "decimal.js"; +import { + calculateFixedIncomeValue, + daysToMaturity, +} from "@/core/services/fixedIncome.service"; + +describe("calculateFixedIncomeValue", () => { + it("CDI 110% por 1 ano", () => { + const v = calculateFixedIncomeValue( + { assetId: "x", kind: "CDB", indexer: "CDI", rate: 110, maturityDate: null, issuer: null, currentValue: null }, + new Decimal(10000), + new Decimal(1), + new Decimal(13.65), + ); + // 10000 * (1 + 0.1365 * 1.10)^1 = 10000 * 1.15015 = 11501.50 + expect(v.toNumber()).toBe(11501.5); + }); + + it("IPCA+ 6% por 1 ano (IPCA=4.5%)", () => { + const v = calculateFixedIncomeValue( + { assetId: "x", kind: "TESOURO", indexer: "IPCA", rate: 6, maturityDate: null, issuer: null, currentValue: null }, + new Decimal(10000), + new Decimal(1), + new Decimal(13.65), + new Decimal(4.5), + ); + // 10000 * 1.06 * 1.045 = 11077 + expect(v.toNumber()).toBe(11077); + }); + + it("Prefixado 12% por 2 anos", () => { + const v = calculateFixedIncomeValue( + { assetId: "x", kind: "CDB", indexer: "PRE", rate: 12, maturityDate: null, issuer: null, currentValue: null }, + new Decimal(10000), + new Decimal(2), + ); + // 10000 * 1.12^2 = 12544 + expect(v.toNumber()).toBe(12544); + }); + + it("Prefixado 0% retorna principal", () => { + const v = calculateFixedIncomeValue( + { assetId: "x", kind: "CDB", indexer: "PRE", rate: 0, maturityDate: null, issuer: null, currentValue: null }, + new Decimal(10000), + new Decimal(5), + ); + expect(v.toNumber()).toBe(10000); + }); +}); + +describe("daysToMaturity", () => { + it("futuro: retorna positivo", () => { + const future = new Date(); + future.setDate(future.getDate() + 30); + const iso = future.toISOString().substring(0, 10); + const days = daysToMaturity(iso); + expect(days).toBeGreaterThan(28); + expect(days).toBeLessThanOrEqual(31); + }); + + it("passado: retorna negativo", () => { + const past = new Date(); + past.setDate(past.getDate() - 10); + const iso = past.toISOString().substring(0, 10); + const days = daysToMaturity(iso); + expect(days).toBeLessThan(0); + }); + + it("null: retorna null", () => { + expect(daysToMaturity(null)).toBeNull(); + }); +}); \ No newline at end of file diff --git a/src/test/portfolio.service.test.ts b/src/test/portfolio.service.test.ts new file mode 100644 index 0000000..f46ca00 --- /dev/null +++ b/src/test/portfolio.service.test.ts @@ -0,0 +1,197 @@ +import { describe, it, expect } from "vitest"; +import Decimal from "decimal.js"; +import { + calculatePosition, + calculatePortfolio, + summarizePortfolio, +} from "@/core/services/portfolio.service"; +import type { Asset, Transaction } from "@/core/domain/portfolio.types"; + +const asset: Asset = { + id: "a1", + ticker: "PETR4", + name: "Petrobras PN", + type: "STOCK", + currency: "BRL", + exchange: "B3", + sector: "Energia", + metadata: {}, +}; + +function tx(partial: Partial): Transaction { + return { + id: partial.id ?? Math.random().toString(), + assetId: partial.assetId ?? "a1", + type: partial.type ?? "BUY", + quantity: partial.quantity ?? 0, + unitPrice: partial.unitPrice ?? 0, + total: partial.total ?? 0, + fees: partial.fees ?? 0, + currency: partial.currency ?? "BRL", + date: partial.date ?? "2024-01-01", + notes: partial.notes ?? null, + }; +} + +describe("calculatePosition", () => { + it("retorna zeros sem transações", () => { + const p = calculatePosition(asset, []); + expect(p.quantity.toNumber()).toBe(0); + expect(p.totalCost.toNumber()).toBe(0); + expect(p.avgPrice.toNumber()).toBe(0); + expect(p.realizedPnL.toNumber()).toBe(0); + }); + + it("compra simples: qty=100 @ R$30", () => { + const p = calculatePosition(asset, [ + tx({ quantity: 100, unitPrice: 30, total: 3000 }), + ]); + expect(p.quantity.toNumber()).toBe(100); + expect(p.avgPrice.toNumber()).toBe(30); + expect(p.totalCost.toNumber()).toBe(3000); + expect(p.realizedPnL.toNumber()).toBe(0); + }); + + it("duas compras: preço médio ponderado", () => { + const p = calculatePosition(asset, [ + tx({ id: "1", date: "2024-01-01", quantity: 100, unitPrice: 20, total: 2000 }), + tx({ id: "2", date: "2024-02-01", quantity: 100, unitPrice: 40, total: 4000 }), + ]); + expect(p.quantity.toNumber()).toBe(200); + expect(p.totalCost.toNumber()).toBe(6000); + expect(p.avgPrice.toNumber()).toBe(30); // (2000+4000)/200 + }); + + it("venda parcial com lucro", () => { + const p = calculatePosition(asset, [ + tx({ id: "1", date: "2024-01-01", quantity: 100, unitPrice: 30, total: 3000 }), + tx({ id: "2", date: "2024-03-01", type: "SELL", quantity: 50, unitPrice: 50, total: 2500 }), + ]); + expect(p.quantity.toNumber()).toBe(50); + expect(p.totalCost.toNumber()).toBe(1500); // avg 30 * 50 + expect(p.avgPrice.toNumber()).toBe(30); + expect(p.realizedPnL.toNumber()).toBe(1000); // (50-30)*50 + }); + + it("venda parcial com prejuízo", () => { + const p = calculatePosition(asset, [ + tx({ id: "1", date: "2024-01-01", quantity: 100, unitPrice: 30, total: 3000 }), + tx({ id: "2", date: "2024-03-01", type: "SELL", quantity: 50, unitPrice: 20, total: 1000 }), + ]); + expect(p.quantity.toNumber()).toBe(50); + expect(p.realizedPnL.toNumber()).toBe(-500); // (20-30)*50 + }); + + it("venda total zera posição", () => { + const p = calculatePosition(asset, [ + tx({ id: "1", date: "2024-01-01", quantity: 100, unitPrice: 30, total: 3000 }), + tx({ id: "2", date: "2024-03-01", type: "SELL", quantity: 100, unitPrice: 40, total: 4000 }), + ]); + expect(p.quantity.toNumber()).toBe(0); + expect(p.totalCost.toNumber()).toBe(0); + expect(p.realizedPnL.toNumber()).toBe(1000); + }); + + it("venda sem posição é ignorada (não dá short)", () => { + const p = calculatePosition(asset, [ + tx({ id: "1", date: "2024-03-01", type: "SELL", quantity: 50, unitPrice: 40, total: 2000 }), + ]); + expect(p.quantity.toNumber()).toBe(0); + expect(p.realizedPnL.toNumber()).toBe(0); + }); + + it("dividendos não alteram posição mas são acumulados", () => { + const p = calculatePosition(asset, [ + tx({ id: "1", date: "2024-01-01", quantity: 100, unitPrice: 30, total: 3000 }), + tx({ id: "2", date: "2024-04-01", type: "DIVIDEND", quantity: 0, unitPrice: 0, total: 150 }), + ]); + expect(p.quantity.toNumber()).toBe(100); + expect(p.dividendsReceived.toNumber()).toBe(150); + }); + + it("calcula P&L não realizado dado preço de mercado", () => { + const p = calculatePosition( + asset, + [tx({ id: "1", date: "2024-01-01", quantity: 100, unitPrice: 30, total: 3000 })], + new Decimal(35), + ); + expect(p.currentValue?.toNumber()).toBe(3500); + expect(p.unrealizedPnL?.toNumber()).toBe(500); + expect(p.totalReturn?.toNumber()).toBe(500); + }); + + it("calcula retorno total incluindo dividendos", () => { + const p = calculatePosition( + asset, + [ + tx({ id: "1", date: "2024-01-01", quantity: 100, unitPrice: 30, total: 3000 }), + tx({ id: "2", date: "2024-04-01", type: "DIVIDEND", total: 200 }), + ], + new Decimal(40), + ); + // valor atual 4000 - custo 3000 = 1000 unrealized + // + realized 0 + // + dividends 200 + // total = 1200 + expect(p.totalReturn?.toNumber()).toBe(1200); + expect(p.totalReturnPercent?.toNumber()).toBe(40); // 1200/3000 * 100 + }); +}); + +describe("calculatePortfolio", () => { + it("agrupa transações por assetId", () => { + const assets: Asset[] = [ + asset, + { ...asset, id: "a2", ticker: "VALE3", name: "Vale" }, + ]; + const txs: Transaction[] = [ + tx({ id: "1", assetId: "a1", quantity: 100, unitPrice: 30, total: 3000 }), + tx({ id: "2", assetId: "a2", quantity: 50, unitPrice: 70, total: 3500 }), + ]; + const positions = calculatePortfolio(assets, txs, { + a1: new Decimal(35), + a2: new Decimal(80), + }); + expect(positions).toHaveLength(2); + const petr = positions.find((p) => p.asset.id === "a1"); + const vale = positions.find((p) => p.asset.id === "a2"); + expect(petr?.currentValue?.toNumber()).toBe(3500); + expect(vale?.currentValue?.toNumber()).toBe(4000); + }); + + it("retorna posições mesmo sem transações", () => { + const positions = calculatePortfolio([asset], []); + expect(positions).toHaveLength(1); + expect(positions[0].quantity.toNumber()).toBe(0); + }); +}); + +describe("summarizePortfolio", () => { + it("agrega totais corretamente", () => { + const positions = calculatePortfolio( + [ + asset, + { ...asset, id: "a2", ticker: "VALE3", name: "Vale" }, + ], + [ + tx({ id: "1", assetId: "a1", quantity: 100, unitPrice: 30, total: 3000 }), + tx({ id: "2", assetId: "a2", quantity: 50, unitPrice: 70, total: 3500 }), + ], + { a1: new Decimal(35), a2: new Decimal(80) }, + ); + const summary = summarizePortfolio(positions); + expect(summary.totalCost.toNumber()).toBe(6500); + expect(summary.totalValue.toNumber()).toBe(7500); + expect(summary.totalUnrealized.toNumber()).toBe(1000); + expect(summary.positionCount).toBe(2); + }); + + it("ignora posições sem preço atual no valor total", () => { + const positions = calculatePortfolio([asset], [ + tx({ quantity: 100, unitPrice: 30, total: 3000 }), + ]); + const summary = summarizePortfolio(positions); + expect(summary.totalCost.toNumber()).toBe(3000); + expect(summary.totalValue.toNumber()).toBe(0); + }); +}); \ No newline at end of file diff --git a/src/test/projection.service.test.ts b/src/test/projection.service.test.ts new file mode 100644 index 0000000..dd189a7 --- /dev/null +++ b/src/test/projection.service.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect } from "vitest"; +import Decimal from "decimal.js"; +import { projectPortfolio } from "@/core/services/projection.service"; + +describe("projectPortfolio", () => { + it("sem aporte, sem retirada: cresce a 12% a.a.", () => { + const r = projectPortfolio({ + initialAmount: new Decimal(10000), + monthlyContribution: 0, + annualRate: 12, + years: 1, + }); + // Com capitalização mensal, 10000 * 1.12 = 11200 exatamente + expect(r.finalBalance.toNumber()).toBeGreaterThan(11199); + expect(r.finalBalance.toNumber()).toBeLessThanOrEqual(11200); + expect(r.totalContributed.toNumber()).toBe(10000); + }); + + it("com aporte mensal de R$ 1000, 12% a.a., 10 anos", () => { + const r = projectPortfolio({ + initialAmount: new Decimal(10000), + monthlyContribution: new Decimal(1000), + annualRate: 12, + years: 10, + }); + // FV ≈ 10000*1.12^10 + 1000*((1.12^10 - 1)/r_mensal * 1.00949) ≈ 255000 + expect(r.finalBalance.toNumber()).toBeGreaterThan(240000); + expect(r.finalBalance.toNumber()).toBeLessThan(260000); + expect(r.totalContributed.toNumber()).toBe(130000); // 10000 + 120000 + }); + + it("inclui poder de compra real quando inflação fornecida", () => { + const r = projectPortfolio({ + initialAmount: new Decimal(10000), + monthlyContribution: 0, + annualRate: 12, + years: 10, + inflationRate: 4.5, + }); + expect(r.realFinalBalance).not.toBeNull(); + expect(r.realFinalBalance!.lessThan(r.finalBalance)).toBe(true); + }); + + it("timeline tem years+1 pontos (incluindo ano 0)", () => { + const r = projectPortfolio({ + initialAmount: new Decimal(10000), + monthlyContribution: 0, + annualRate: 10, + years: 5, + }); + expect(r.timeline).toHaveLength(6); + expect(r.timeline[0].yearLabel).toBe("Hoje"); + expect(r.timeline[5].yearLabel).toBe("+5a"); + }); + + it("retirada mensal reduz o patrimônio", () => { + const r = projectPortfolio({ + initialAmount: new Decimal(100000), + monthlyContribution: 0, + annualRate: 8, + years: 10, + monthlyWithdrawal: new Decimal(500), + }); + // deveria ser menor do que sem retirada + const baseline = projectPortfolio({ + initialAmount: new Decimal(100000), + monthlyContribution: 0, + annualRate: 8, + years: 10, + }); + expect(r.finalBalance.toNumber()).toBeLessThan(baseline.finalBalance.toNumber()); + }); +}); \ No newline at end of file diff --git a/supabase/README.md b/supabase/README.md new file mode 100644 index 0000000..4e4a0a9 --- /dev/null +++ b/supabase/README.md @@ -0,0 +1,21 @@ +# Supabase + +Crie um projeto em https://supabase.com e rode a migration `supabase/migrations/20251003000000_init.sql` no SQL Editor do painel. + +```bash +# .env.local +NEXT_PUBLIC_SUPABASE_URL=https://xxxxx.supabase.co +NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJ... +``` + +## Estrutura + +- `supabase/migrations/` — arquivos SQL versionados +- `src/lib/supabase/client.ts` — client para componentes do browser +- `src/lib/supabase/server.ts` — client para Server Components / Server Actions +- `src/lib/supabase/middleware.ts` — refresh de sessão (chamado por `middleware.ts` na raiz) +- `src/lib/supabase/types.ts` — tipos manuais (regeneráveis com `supabase gen types`) + +## Auth + +Email/senha habilitado por padrão no Supabase. Para desabilitar confirmação de email durante desenvolvimento: Authentication → Providers → Email → "Confirm email" = OFF. \ No newline at end of file diff --git a/supabase/migrations/20251003000000_init.sql b/supabase/migrations/20251003000000_init.sql new file mode 100644 index 0000000..ec61d9a --- /dev/null +++ b/supabase/migrations/20251003000000_init.sql @@ -0,0 +1,224 @@ +-- ============================================================================ +-- FinForge - Initial Schema +-- ============================================================================ +-- Tabelas: +-- profiles, assets, fixed_income_details, transactions, price_cache +-- ============================================================================ + +-- ---------------------------------------------------------------------------- +-- profiles: dados públicos do usuário (1:1 com auth.users) +-- ---------------------------------------------------------------------------- +create table if not exists public.profiles ( + id uuid primary key references auth.users(id) on delete cascade, + display_name text, + base_currency text not null default 'BRL', + expected_rates jsonb not null default '{ + "STOCK": 0.12, + "ETF": 0.10, + "FII": 0.10, + "CRYPTO": 0.20, + "FIXED_INCOME": 0.11, + "CASH": 0.01 + }'::jsonb, + ipca_annual numeric not null default 4.5, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +-- ---------------------------------------------------------------------------- +-- assets: ativos da carteira do usuário +-- ---------------------------------------------------------------------------- +create table if not exists public.assets ( + id uuid primary key default gen_random_uuid(), + user_id uuid not null references auth.users(id) on delete cascade, + ticker text not null, + name text not null, + type text not null check (type in ('STOCK','ETF','FII','CRYPTO','FIXED_INCOME','CASH')), + currency text not null default 'BRL' check (currency in ('BRL','USD')), + exchange text, + sector text, + metadata jsonb not null default '{}'::jsonb, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + unique (user_id, ticker, type) +); + +create index if not exists idx_assets_user_id on public.assets(user_id); +create index if not exists idx_assets_type on public.assets(type); + +-- ---------------------------------------------------------------------------- +-- fixed_income_details: detalhes específicos de renda fixa (1:1 opcional) +-- ---------------------------------------------------------------------------- +create table if not exists public.fixed_income_details ( + asset_id uuid primary key references public.assets(id) on delete cascade, + kind text not null check (kind in ('CDB','LCI','LCA','TESOURO','DEBENTURE','CRI','CRA','LC','OTHER')), + indexer text not null check (indexer in ('PRE','CDI','IPCA','SELIC','OTHER')), + rate numeric not null, -- ex: 110 = 110% do CDI; 13.5 = 13.5% a.a. prefixado; 6 = IPCA+6% + maturity_date date, + issuer text, + current_value numeric -- valor atualizado manualmente (RF não tem cotação de mercado) +); + +-- ---------------------------------------------------------------------------- +-- transactions: compras, vendas, dividendos, aportes em caixa +-- ---------------------------------------------------------------------------- +create table if not exists public.transactions ( + id uuid primary key default gen_random_uuid(), + user_id uuid not null references auth.users(id) on delete cascade, + asset_id uuid not null references public.assets(id) on delete cascade, + type text not null check (type in ('BUY','SELL','DIVIDEND','INCOME','SPLIT','BONUS')), + quantity numeric not null default 0, + unit_price numeric not null default 0, + total numeric not null default 0, -- quantity * unit_price + fees (em moeda da transação) + fees numeric not null default 0, + currency text not null default 'BRL' check (currency in ('BRL','USD')), + date date not null, + notes text, + created_at timestamptz not null default now() +); + +create index if not exists idx_transactions_user_id on public.transactions(user_id); +create index if not exists idx_transactions_asset_id on public.transactions(asset_id); +create index if not exists idx_transactions_date on public.transactions(date desc); + +-- ---------------------------------------------------------------------------- +-- price_cache: cache de cotações (compartilhado, leitura pública) +-- ---------------------------------------------------------------------------- +create table if not exists public.price_cache ( + symbol text not null, + currency text not null, + price numeric not null, + source text not null, + updated_at timestamptz not null default now(), + primary key (symbol, currency) +); + +create index if not exists idx_price_cache_updated_at on public.price_cache(updated_at desc); + +-- ============================================================================ +-- ROW LEVEL SECURITY +-- ============================================================================ + +alter table public.profiles enable row level security; +alter table public.assets enable row level security; +alter table public.fixed_income_details enable row level security; +alter table public.transactions enable row level security; +alter table public.price_cache enable row level security; + +-- profiles: usuário só vê/edita o próprio perfil +drop policy if exists "profiles_select_own" on public.profiles; +create policy "profiles_select_own" on public.profiles + for select using (auth.uid() = id); + +drop policy if exists "profiles_insert_own" on public.profiles; +create policy "profiles_insert_own" on public.profiles + for insert with check (auth.uid() = id); + +drop policy if exists "profiles_update_own" on public.profiles; +create policy "profiles_update_own" on public.profiles + for update using (auth.uid() = id); + +-- assets: isolamento por user_id +drop policy if exists "assets_select_own" on public.assets; +create policy "assets_select_own" on public.assets + for select using (auth.uid() = user_id); + +drop policy if exists "assets_insert_own" on public.assets; +create policy "assets_insert_own" on public.assets + for insert with check (auth.uid() = user_id); + +drop policy if exists "assets_update_own" on public.assets; +create policy "assets_update_own" on public.assets + for update using (auth.uid() = user_id); + +drop policy if exists "assets_delete_own" on public.assets; +create policy "assets_delete_own" on public.assets + for delete using (auth.uid() = user_id); + +-- fixed_income_details: usuário só vê detalhes de ativos próprios +drop policy if exists "fixed_income_select_own" on public.fixed_income_details; +create policy "fixed_income_select_own" on public.fixed_income_details + for select using ( + exists (select 1 from public.assets a where a.id = asset_id and a.user_id = auth.uid()) + ); + +drop policy if exists "fixed_income_insert_own" on public.fixed_income_details; +create policy "fixed_income_insert_own" on public.fixed_income_details + for insert with check ( + exists (select 1 from public.assets a where a.id = asset_id and a.user_id = auth.uid()) + ); + +drop policy if exists "fixed_income_update_own" on public.fixed_income_details; +create policy "fixed_income_update_own" on public.fixed_income_details + for update using ( + exists (select 1 from public.assets a where a.id = asset_id and a.user_id = auth.uid()) + ); + +drop policy if exists "fixed_income_delete_own" on public.fixed_income_details; +create policy "fixed_income_delete_own" on public.fixed_income_details + for delete using ( + exists (select 1 from public.assets a where a.id = asset_id and a.user_id = auth.uid()) + ); + +-- transactions: isolamento por user_id +drop policy if exists "transactions_select_own" on public.transactions; +create policy "transactions_select_own" on public.transactions + for select using (auth.uid() = user_id); + +drop policy if exists "transactions_insert_own" on public.transactions; +create policy "transactions_insert_own" on public.transactions + for insert with check (auth.uid() = user_id); + +drop policy if exists "transactions_update_own" on public.transactions; +create policy "transactions_update_own" on public.transactions + for update using (auth.uid() = user_id); + +drop policy if exists "transactions_delete_own" on public.transactions; +create policy "transactions_delete_own" on public.transactions + for delete using (auth.uid() = user_id); + +-- price_cache: leitura pública, escrita apenas service_role (via API route) +drop policy if exists "price_cache_select_public" on public.price_cache; +create policy "price_cache_select_public" on public.price_cache + for select using (true); + +-- ============================================================================ +-- TRIGGERS +-- ============================================================================ + +-- Atualizar updated_at automaticamente +create or replace function public.set_updated_at() +returns trigger as $$ +begin + new.updated_at = now(); + return new; +end; +$$ language plpgsql; + +drop trigger if exists trg_profiles_updated_at on public.profiles; +create trigger trg_profiles_updated_at + before update on public.profiles + for each row execute function public.set_updated_at(); + +drop trigger if exists trg_assets_updated_at on public.assets; +create trigger trg_assets_updated_at + before update on public.assets + for each row execute function public.set_updated_at(); + +-- ============================================================================ +-- AUTO-CRIAR PROFILE NO SIGNUP +-- ============================================================================ +create or replace function public.handle_new_user() +returns trigger as $$ +begin + insert into public.profiles (id, display_name) + values (new.id, coalesce(new.raw_user_meta_data->>'display_name', split_part(new.email, '@', 1))) + on conflict (id) do nothing; + return new; +end; +$$ language plpgsql security definer; + +drop trigger if exists on_auth_user_created on auth.users; +create trigger on_auth_user_created + after insert on auth.users + for each row execute function public.handle_new_user(); \ No newline at end of file From 204794e6c4ea357c8ac53e0e24fc4e1cedf0f9d4 Mon Sep 17 00:00:00 2001 From: Gabriel Chioquetta Date: Sat, 3 Oct 2026 23:00:28 -0300 Subject: [PATCH 2/4] fix: mark AllocationView as client component to fix Vercel build Vercel build failed with 'Super expression must either be null or function' on /portfolio/allocation because AllocationView.tsx used recharts (class components with browser dependencies) without 'use client' directive. Add 'use client' to AllocationView.tsx. Other recharts components (ProjectionPanel, Simulator, DividendsChart) already had it. --- src/components/portfolio/AllocationView.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/components/portfolio/AllocationView.tsx b/src/components/portfolio/AllocationView.tsx index ef3f64b..8069e51 100644 --- a/src/components/portfolio/AllocationView.tsx +++ b/src/components/portfolio/AllocationView.tsx @@ -1,3 +1,5 @@ +"use client"; + import { AlertTriangle, PieChart } from "lucide-react"; import { Pie, From 638bfa2b0722f204e47436a622dd5bb1f4cd9e94 Mon Sep 17 00:00:00 2001 From: Gabriel Chioquetta Date: Sat, 3 Oct 2026 23:03:21 -0300 Subject: [PATCH 3/4] fix: make Supabase middleware resilient to missing env vars during build During Vercel build, the Supabase middleware was called for static page prerendering (/compound-interest, /fire-calculator, etc.) and threw 'Your project's URL and API key are required to create a Supabase client!' when env vars weren't configured in Vercel project settings. Make middleware gracefully skip auth refresh when env vars are missing, and wrap getUser() in try/catch to avoid breaking builds on network errors. Pages still work: - Public calculators: don't need auth, render normally - Protected portfolio pages: redirect via layout-level server checks --- src/lib/supabase/middleware.ts | 48 ++++++++++++++++++++-------------- 1 file changed, 29 insertions(+), 19 deletions(-) diff --git a/src/lib/supabase/middleware.ts b/src/lib/supabase/middleware.ts index ba33ee1..12b9a58 100644 --- a/src/lib/supabase/middleware.ts +++ b/src/lib/supabase/middleware.ts @@ -5,29 +5,39 @@ import type { Database } from "./types"; export async function updateSession(request: NextRequest) { let response = NextResponse.next({ request }); - const supabase = createServerClient( - process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, - { - cookies: { - getAll() { - return request.cookies.getAll(); - }, - setAll(cookiesToSet) { - cookiesToSet.forEach(({ name, value }) => - request.cookies.set(name, value), - ); - response = NextResponse.next({ request }); - cookiesToSet.forEach(({ name, value, options }) => - response.cookies.set(name, value, options), - ); - }, + const url = process.env.NEXT_PUBLIC_SUPABASE_URL; + const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; + + // Sem env vars (ex: build/prerrocess) → passa reto sem auth. + // Páginas públicas continuam funcionando; protegidas vão redirecionar via layouts. + if (!url || !anonKey) { + return response; + } + + const supabase = createServerClient(url, anonKey, { + cookies: { + getAll() { + return request.cookies.getAll(); + }, + setAll(cookiesToSet) { + cookiesToSet.forEach(({ name, value }) => + request.cookies.set(name, value), + ); + response = NextResponse.next({ request }); + cookiesToSet.forEach(({ name, value, options }) => + response.cookies.set(name, value, options), + ); }, }, - ); + }); // CRÍTICO: refresh da sessão do usuário — não remover. - await supabase.auth.getUser(); + // Envelopado em try para não quebrar build se houver erro de rede. + try { + await supabase.auth.getUser(); + } catch { + // ignora — sem sessão válida é OK + } return response; } \ No newline at end of file From 2587f6cc3e024bb5bc1982f97d7e8b44f7d49b59 Mon Sep 17 00:00:00 2001 From: Gabriel Chioquetta Date: Sat, 3 Oct 2026 23:11:24 -0300 Subject: [PATCH 4/4] fix: make Supabase server and browser clients resilient to missing env vars Vercel preview deployments and local builds without .env.local were failing prerender with: 'Your project's URL and API key are required to create a Supabase client!' Even pages like /transactions that don't use Supabase auth were failing because Next.js evaluates module graph for page data collection, and the UserMenu (used in CalculatorLayout header) calls useAuth which creates a browser client. Fix: - createClient in client.ts and server.ts now return a stub proxy when env vars are missing instead of throwing - Proxy chains .from().select().order().limit() and resolves with { data: null, error: { message: 'Supabase env vars ausentes' } } for any await - Auth methods return error responses Pages render without Supabase data when env vars are absent (which is fine for builds/preview), and work normally when env vars are present. --- src/lib/supabase/client.ts | 48 ++++++++++++++++++++++++--- src/lib/supabase/server.ts | 68 ++++++++++++++++++++++++++++---------- 2 files changed, 95 insertions(+), 21 deletions(-) diff --git a/src/lib/supabase/client.ts b/src/lib/supabase/client.ts index 501597d..889765a 100644 --- a/src/lib/supabase/client.ts +++ b/src/lib/supabase/client.ts @@ -2,8 +2,48 @@ import { createBrowserClient } from "@supabase/ssr"; import type { Database } from "./types"; export function createClient() { - return createBrowserClient( - process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, - ); + const url = process.env.NEXT_PUBLIC_SUPABASE_URL; + const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; + + if (!url || !anonKey) { + // Stub para evitar crash em build/prerrocess sem env vars. + // Métodos retornam respostas vazias; auth sempre "sem usuário". + return { + auth: { + async getUser() { + return { data: { user: null }, error: { message: "Supabase env vars ausentes" } }; + }, + async signInWithPassword() { + return { data: { user: null, session: null }, error: { message: "Supabase env vars ausentes" } }; + }, + async signUp() { + return { data: { user: null, session: null }, error: { message: "Supabase env vars ausentes" } }; + }, + async signOut() { + return { error: null }; + }, + onAuthStateChange() { + return { data: { subscription: { unsubscribe() {} } } }; + }, + }, + from() { + // Cada chamada encadeada retorna um objeto thenable. + // Final do chain (await) resolve com { data: null, error }. + const makeThenable = () => { + const t: Record = {}; + t.then = (resolve: (v: unknown) => void) => + resolve({ data: null, error: { message: "Supabase env vars ausentes" } }); + return new Proxy(t, { + get(target, prop) { + if (prop === "then") return target.then; + return () => makeThenable(); + }, + }); + }; + return makeThenable(); + }, + } as unknown as ReturnType>; + } + + return createBrowserClient(url, anonKey); } \ No newline at end of file diff --git a/src/lib/supabase/server.ts b/src/lib/supabase/server.ts index a614fb0..b3561eb 100644 --- a/src/lib/supabase/server.ts +++ b/src/lib/supabase/server.ts @@ -3,26 +3,60 @@ import { cookies } from "next/headers"; import type { Database } from "./types"; export async function createClient() { - const cookieStore = await cookies(); + const url = process.env.NEXT_PUBLIC_SUPABASE_URL; + const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; - return createServerClient( - process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, - { - cookies: { - getAll() { - return cookieStore.getAll(); + // Sem env vars (ex: build sem .env) → retorna proxy que retorna erro + // de forma controlada em vez de crashar o módulo inteiro. + if (!url || !anonKey) { + return { + auth: { + async getUser() { + return { data: { user: null }, error: { message: "Supabase env vars ausentes" } }; + }, + async signInWithPassword() { + return { data: { user: null, session: null }, error: { message: "Supabase env vars ausentes" } }; + }, + async signUp() { + return { data: { user: null, session: null }, error: { message: "Supabase env vars ausentes" } }; }, - setAll(cookiesToSet) { - try { - cookiesToSet.forEach(({ name, value, options }) => - cookieStore.set(name, value, options), - ); - } catch { - // Server Component não pode setar cookies — tratado pelo middleware. - } + async signOut() { + return { error: null }; }, }, + from() { + const makeThenable = () => { + const t: Record = {}; + t.then = (resolve: (v: unknown) => void) => + resolve({ data: null, error: { message: "Supabase env vars ausentes" } }); + return new Proxy(t, { + get(target, prop) { + if (prop === "then") return target.then; + return () => makeThenable(); + }, + }); + }; + return makeThenable(); + }, + } as unknown as Awaited>>; + } + + const cookieStore = await cookies(); + + return createServerClient(url, anonKey, { + cookies: { + getAll() { + return cookieStore.getAll(); + }, + setAll(cookiesToSet) { + try { + cookiesToSet.forEach(({ name, value, options }) => + cookieStore.set(name, value, options), + ); + } catch { + // Server Component não pode setar cookies — tratado pelo middleware. + } + }, }, - ); + }); } \ No newline at end of file