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}
+
+
+
+
+
+ >
+ );
+}
\ 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
+
+
+
+
+ | Ativo |
+ Total recebido |
+ Yield on cost |
+
+
+
+ {summary.byAsset.map((a) => (
+
+ | {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.
+
+
+
+
+
+
+
+
+ ) : (
+
+
+
+
+
+
+ | Ativo |
+ Qtd |
+ Preço médio |
+ Custo total |
+ Preço atual |
+ Valor atual |
+ P&L |
+
+
+
+ {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 (
+
+ |
+
+ {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.
+
+
+
+
+
+ ) : (
+
+
+
+
+
+
+ | Data |
+ Ativo |
+ Tipo |
+ Qtd |
+ Preço |
+ Total |
+ Obs |
+ |
+
+
+
+ {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 (
+
+ |
+ {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 ? (
+ <>
+ 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..8069e51
--- /dev/null
+++ b/src/components/portfolio/AllocationView.tsx
@@ -0,0 +1,203 @@
+"use client";
+
+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
+
+
+
+
+ | Ticker |
+ Valor |
+ % |
+
+
+
+ {byAsset
+ .sort((a, b) => b.percentage.minus(a.percentage).toNumber())
+ .map((a) => (
+
+ | {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 (
+
+ );
+}
\ 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.
+
+
+
+
+
+ {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 (
+
+ );
+}
\ 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 (
+
+
+
+
+
+
+
+ setInitial(Number(e.target.value))}
+ />
+
+
+
+ setYears(Math.min(50, v))}
+ displayValue={`${years} anos`}
+ min={1}
+ max={40}
+ step={1}
+ />
+
+
+
+
+
+
+
+
+
+ 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 (
+
+ );
+}
\ 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..889765a
--- /dev/null
+++ b/src/lib/supabase/client.ts
@@ -0,0 +1,49 @@
+import { createBrowserClient } from "@supabase/ssr";
+import type { Database } from "./types";
+
+export function createClient() {
+ 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/middleware.ts b/src/lib/supabase/middleware.ts
new file mode 100644
index 0000000..12b9a58
--- /dev/null
+++ b/src/lib/supabase/middleware.ts
@@ -0,0 +1,43 @@
+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 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.
+ // 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
diff --git a/src/lib/supabase/server.ts b/src/lib/supabase/server.ts
new file mode 100644
index 0000000..b3561eb
--- /dev/null
+++ b/src/lib/supabase/server.ts
@@ -0,0 +1,62 @@
+import { createServerClient } from "@supabase/ssr";
+import { cookies } from "next/headers";
+import type { Database } from "./types";
+
+export async function createClient() {
+ const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
+ const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
+
+ // 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" } };
+ },
+ 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
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