diff --git a/apps/api/cmd/server/main.go b/apps/api/cmd/server/main.go index 1da175d..fde1d4b 100644 --- a/apps/api/cmd/server/main.go +++ b/apps/api/cmd/server/main.go @@ -70,7 +70,8 @@ func main() { recurringHandler := handler.NewRecurringHandler(recurringSvc) accountRepo := repository.NewAccountRepository(pool) - accountSvc := service.NewAccountService(accountRepo) + cdiSvc := service.NewCDIYieldService(accountRepo, manualTxRepo) + accountSvc := service.NewAccountService(accountRepo, cdiSvc) accountHandler := handler.NewAccountHandler(accountSvc) dashboardRepo := repository.NewDashboardRepository(pool) diff --git a/apps/api/internal/migration/sql/010_cdi_yield.sql b/apps/api/internal/migration/sql/010_cdi_yield.sql new file mode 100644 index 0000000..e53da34 --- /dev/null +++ b/apps/api/internal/migration/sql/010_cdi_yield.sql @@ -0,0 +1,11 @@ +-- Add CDI yield support to accounts +ALTER TABLE accounts + ADD COLUMN IF NOT EXISTS yield_type VARCHAR(10) NOT NULL DEFAULT 'none' + CHECK (yield_type IN ('none', 'cdi', 'variable')), + ADD COLUMN IF NOT EXISTS last_yield_date DATE; + +-- Extend transactions.source check to allow 'yield' +ALTER TABLE transactions DROP CONSTRAINT IF EXISTS transactions_source_check; +ALTER TABLE transactions + ADD CONSTRAINT transactions_source_check + CHECK (source IN ('manual', 'import', 'yield')); diff --git a/apps/api/internal/model/account.go b/apps/api/internal/model/account.go index f3c7d52..de81560 100644 --- a/apps/api/internal/model/account.go +++ b/apps/api/internal/model/account.go @@ -6,6 +6,8 @@ type Account struct { Type string `json:"type"` InitialBalance float64 `json:"initial_balance"` Balance float64 `json:"balance"` + YieldType string `json:"yield_type"` // "none" | "cdi" | "variable" + LastYieldDate *string `json:"last_yield_date,omitempty"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` } @@ -14,4 +16,5 @@ type AccountInput struct { Name string `json:"name"` Type string `json:"type"` InitialBalance float64 `json:"initial_balance"` + YieldType string `json:"yield_type"` // "none" | "cdi" | "variable" } diff --git a/apps/api/internal/repository/account.go b/apps/api/internal/repository/account.go index 4e3656a..1dfe227 100644 --- a/apps/api/internal/repository/account.go +++ b/apps/api/internal/repository/account.go @@ -8,6 +8,17 @@ import ( "financeiro-carvalho/internal/model" ) +// AccountRepoWithYield extends the basic account repo with CDI-related operations. +type AccountRepoWithYield interface { + List(ctx context.Context) ([]model.Account, error) + GetByID(ctx context.Context, id int) (*model.Account, error) + Create(ctx context.Context, in model.AccountInput) (*model.Account, error) + Update(ctx context.Context, id int, in model.AccountInput) (*model.Account, error) + Delete(ctx context.Context, id int) error + TotalPatrimony(ctx context.Context) (float64, error) + UpdateLastYieldDate(ctx context.Context, id int, date string) error +} + type AccountRepository struct { pool *pgxpool.Pool } @@ -20,6 +31,7 @@ func (r *AccountRepository) List(ctx context.Context) ([]model.Account, error) { rows, err := r.pool.Query(ctx, ` SELECT a.id, a.name, a.type, a.initial_balance, + a.yield_type, a.last_yield_date::text, a.created_at::text, a.updated_at::text, a.initial_balance + COALESCE(SUM(CASE WHEN t.type = 'income' THEN t.amount ELSE 0 END), 0) @@ -38,7 +50,7 @@ func (r *AccountRepository) List(ctx context.Context) ([]model.Account, error) { var out []model.Account for rows.Next() { var a model.Account - if err := rows.Scan(&a.ID, &a.Name, &a.Type, &a.InitialBalance, &a.CreatedAt, &a.UpdatedAt, &a.Balance); err != nil { + if err := rows.Scan(&a.ID, &a.Name, &a.Type, &a.InitialBalance, &a.YieldType, &a.LastYieldDate, &a.CreatedAt, &a.UpdatedAt, &a.Balance); err != nil { return nil, err } out = append(out, a) @@ -51,6 +63,7 @@ func (r *AccountRepository) GetByID(ctx context.Context, id int) (*model.Account err := r.pool.QueryRow(ctx, ` SELECT a.id, a.name, a.type, a.initial_balance, + a.yield_type, a.last_yield_date::text, a.created_at::text, a.updated_at::text, a.initial_balance + COALESCE(SUM(CASE WHEN t.type = 'income' THEN t.amount ELSE 0 END), 0) @@ -60,7 +73,7 @@ func (r *AccountRepository) GetByID(ctx context.Context, id int) (*model.Account LEFT JOIN transactions t ON t.account_id = a.id WHERE a.id = $1 GROUP BY a.id - `, id).Scan(&a.ID, &a.Name, &a.Type, &a.InitialBalance, &a.CreatedAt, &a.UpdatedAt, &a.Balance) + `, id).Scan(&a.ID, &a.Name, &a.Type, &a.InitialBalance, &a.YieldType, &a.LastYieldDate, &a.CreatedAt, &a.UpdatedAt, &a.Balance) if err != nil { return nil, err } @@ -68,13 +81,17 @@ func (r *AccountRepository) GetByID(ctx context.Context, id int) (*model.Account } func (r *AccountRepository) Create(ctx context.Context, in model.AccountInput) (*model.Account, error) { + yt := in.YieldType + if yt == "" { + yt = "none" + } var a model.Account err := r.pool.QueryRow(ctx, ` - INSERT INTO accounts (name, type, initial_balance) - VALUES ($1, $2, $3) - RETURNING id, name, type, initial_balance, created_at::text, updated_at::text - `, in.Name, in.Type, in.InitialBalance). - Scan(&a.ID, &a.Name, &a.Type, &a.InitialBalance, &a.CreatedAt, &a.UpdatedAt) + INSERT INTO accounts (name, type, initial_balance, yield_type) + VALUES ($1, $2, $3, $4) + RETURNING id, name, type, initial_balance, yield_type, last_yield_date::text, created_at::text, updated_at::text + `, in.Name, in.Type, in.InitialBalance, yt). + Scan(&a.ID, &a.Name, &a.Type, &a.InitialBalance, &a.YieldType, &a.LastYieldDate, &a.CreatedAt, &a.UpdatedAt) if err != nil { return nil, err } @@ -83,16 +100,19 @@ func (r *AccountRepository) Create(ctx context.Context, in model.AccountInput) ( } func (r *AccountRepository) Update(ctx context.Context, id int, in model.AccountInput) (*model.Account, error) { + yt := in.YieldType + if yt == "" { + yt = "none" + } row := r.pool.QueryRow(ctx, ` - UPDATE accounts SET name=$1, type=$2, initial_balance=$3, updated_at=NOW() - WHERE id=$4 - RETURNING id, name, type, initial_balance, created_at::text, updated_at::text - `, in.Name, in.Type, in.InitialBalance, id) + UPDATE accounts SET name=$1, type=$2, initial_balance=$3, yield_type=$4, updated_at=NOW() + WHERE id=$5 + RETURNING id, name, type, initial_balance, yield_type, last_yield_date::text, created_at::text, updated_at::text + `, in.Name, in.Type, in.InitialBalance, yt, id) var a model.Account - if err := row.Scan(&a.ID, &a.Name, &a.Type, &a.InitialBalance, &a.CreatedAt, &a.UpdatedAt); err != nil { + if err := row.Scan(&a.ID, &a.Name, &a.Type, &a.InitialBalance, &a.YieldType, &a.LastYieldDate, &a.CreatedAt, &a.UpdatedAt); err != nil { return nil, err } - // recalculate balance full, err := r.GetByID(ctx, a.ID) if err != nil { return nil, err @@ -119,3 +139,8 @@ func (r *AccountRepository) TotalPatrimony(ctx context.Context) (float64, error) `).Scan(&total) return total, err } + +func (r *AccountRepository) UpdateLastYieldDate(ctx context.Context, id int, date string) error { + _, err := r.pool.Exec(ctx, `UPDATE accounts SET last_yield_date = $1 WHERE id = $2`, date, id) + return err +} diff --git a/apps/api/internal/service/account.go b/apps/api/internal/service/account.go index 5172c58..d7a613f 100644 --- a/apps/api/internal/service/account.go +++ b/apps/api/internal/service/account.go @@ -5,6 +5,7 @@ import ( "errors" "financeiro-carvalho/internal/model" + "financeiro-carvalho/internal/repository" ) var ( @@ -26,11 +27,12 @@ type AccountRepo interface { } type AccountService struct { - repo AccountRepo + repo repository.AccountRepoWithYield + cdiSvc *CDIYieldService } -func NewAccountService(repo AccountRepo) *AccountService { - return &AccountService{repo: repo} +func NewAccountService(repo repository.AccountRepoWithYield, cdiSvc *CDIYieldService) *AccountService { + return &AccountService{repo: repo, cdiSvc: cdiSvc} } func (s *AccountService) validate(in model.AccountInput) error { @@ -43,7 +45,21 @@ func (s *AccountService) validate(in model.AccountInput) error { return nil } +// List returns accounts, applying CDI yield on-demand for cdi-type accounts. func (s *AccountService) List(ctx context.Context) ([]model.Account, error) { + accounts, err := s.repo.List(ctx) + if err != nil { + return nil, err + } + + for i := range accounts { + if accounts[i].YieldType == "cdi" { + // Apply CDI yield — errors are non-fatal (BCB API may be unavailable) + _ = s.cdiSvc.ApplyYield(ctx, &accounts[i]) + } + } + + // Re-fetch after yield transactions may have been created to get updated balances return s.repo.List(ctx) } diff --git a/apps/api/internal/service/account_test.go b/apps/api/internal/service/account_test.go index 0d0eb72..09e8fa9 100644 --- a/apps/api/internal/service/account_test.go +++ b/apps/api/internal/service/account_test.go @@ -37,11 +37,18 @@ func (m *mockAccountRepo) Update(_ context.Context, id int, in model.AccountInpu } return nil, nil } -func (m *mockAccountRepo) Delete(_ context.Context, _ int) error { return nil } -func (m *mockAccountRepo) TotalPatrimony(_ context.Context) (float64, error) { return 0, nil } +func (m *mockAccountRepo) Delete(_ context.Context, _ int) error { return nil } +func (m *mockAccountRepo) TotalPatrimony(_ context.Context) (float64, error) { return 0, nil } +func (m *mockAccountRepo) UpdateLastYieldDate(_ context.Context, _ int, _ string) error { return nil } + +func newAccountSvc() *service.AccountService { + repo := &mockAccountRepo{} + cdiSvc := service.NewCDIYieldService(repo, &mockTxRepo{}) + return service.NewAccountService(repo, cdiSvc) +} func TestCreateAccount_EmptyName(t *testing.T) { - svc := service.NewAccountService(&mockAccountRepo{}) + svc := newAccountSvc() _, err := svc.Create(context.Background(), model.AccountInput{Name: "", Type: "checking", InitialBalance: 0}) if err != service.ErrAccountEmptyName { t.Fatalf("expected ErrAccountEmptyName, got %v", err) @@ -49,7 +56,7 @@ func TestCreateAccount_EmptyName(t *testing.T) { } func TestCreateAccount_InvalidType(t *testing.T) { - svc := service.NewAccountService(&mockAccountRepo{}) + svc := newAccountSvc() _, err := svc.Create(context.Background(), model.AccountInput{Name: "Nubank", Type: "bitcoin"}) if err != service.ErrAccountInvalidType { t.Fatalf("expected ErrAccountInvalidType, got %v", err) @@ -57,7 +64,7 @@ func TestCreateAccount_InvalidType(t *testing.T) { } func TestCreateAccount_OK(t *testing.T) { - svc := service.NewAccountService(&mockAccountRepo{}) + svc := newAccountSvc() a, err := svc.Create(context.Background(), model.AccountInput{Name: "Nubank", Type: "checking", InitialBalance: 1000}) if err != nil { t.Fatal(err) diff --git a/apps/api/internal/service/cdi.go b/apps/api/internal/service/cdi.go new file mode 100644 index 0000000..72f640b --- /dev/null +++ b/apps/api/internal/service/cdi.go @@ -0,0 +1,139 @@ +package service + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + "financeiro-carvalho/internal/model" + "financeiro-carvalho/internal/repository" +) + +const bcbCDIURL = "https://api.bcb.gov.br/dados/serie/bcdata.sgs.12/dados?formato=json&dataInicial=%s&dataFinal=%s" + +type CDIYieldService struct { + accountRepo repository.AccountRepoWithYield + txRepo repository.ManualTransactionRepository + client *http.Client +} + +func NewCDIYieldService(accountRepo repository.AccountRepoWithYield, txRepo repository.ManualTransactionRepository) *CDIYieldService { + return &CDIYieldService{ + accountRepo: accountRepo, + txRepo: txRepo, + client: &http.Client{Timeout: 10 * time.Second}, + } +} + +// ApplyYield fetches CDI rates and creates a yield transaction for the account if applicable. +func (s *CDIYieldService) ApplyYield(ctx context.Context, a *model.Account) error { + if a.YieldType != "cdi" { + return nil + } + + // Determine the start date (day after last yield) + yesterday := time.Now().AddDate(0, 0, -1).Format("2006-01-02") + startDate := a.CreatedAt[:10] // default: account creation date + if a.LastYieldDate != nil && *a.LastYieldDate != "" { + startDate = *a.LastYieldDate + // start is exclusive: add one day + t, err := time.Parse("2006-01-02", startDate) + if err != nil { + return nil + } + startDate = t.AddDate(0, 0, 1).Format("2006-01-02") + } + + if startDate > yesterday { + // nothing to calculate yet + return nil + } + + rates, err := s.fetchCDIRates(startDate, yesterday) + if err != nil { + // BCB API unreachable — skip silently; will retry next load + return nil + } + if len(rates) == 0 { + return nil + } + + // Fill gaps (weekends/holidays) using last known rate + rateMap := make(map[string]float64, len(rates)) + for _, r := range rates { + rateMap[r.date] = r.value + } + + compound := 1.0 + lastRate := rates[0].value + start, _ := time.Parse("2006-01-02", startDate) + end, _ := time.Parse("2006-01-02", yesterday) + for d := start; !d.After(end); d = d.AddDate(0, 0, 1) { + key := d.Format("2006-01-02") + if r, ok := rateMap[key]; ok { + lastRate = r + } + compound *= 1 + lastRate/100 + } + + yieldAmount := a.Balance * (compound - 1) + if yieldAmount <= 0 { + return nil + } + + period := fmt.Sprintf("%s → %s", startDate, yesterday) + tx := model.Transaction{ + Date: time.Now().Format("2006-01-02"), + Amount: yieldAmount, + Description: fmt.Sprintf("Rendimento CDI — %s", period), + Type: "income", + Source: "yield", + AccountID: &a.ID, + } + if _, err := s.txRepo.Create(ctx, tx); err != nil { + return err + } + + return s.accountRepo.UpdateLastYieldDate(ctx, a.ID, yesterday) +} + +type bcbEntry struct { + date string + value float64 +} + +func (s *CDIYieldService) fetchCDIRates(startDate, endDate string) ([]bcbEntry, error) { + // BCB API uses DD/MM/YYYY format + start, _ := time.Parse("2006-01-02", startDate) + end, _ := time.Parse("2006-01-02", endDate) + url := fmt.Sprintf(bcbCDIURL, start.Format("02/01/2006"), end.Format("02/01/2006")) + + resp, err := s.client.Get(url) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var raw []struct { + Data string `json:"data"` + Valor string `json:"valor"` + } + if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil { + return nil, err + } + + var out []bcbEntry + for _, r := range raw { + // BCB date format: DD/MM/YYYY → convert to YYYY-MM-DD + t, err := time.Parse("02/01/2006", r.Data) + if err != nil { + continue + } + var v float64 + fmt.Sscanf(r.Valor, "%f", &v) + out = append(out, bcbEntry{date: t.Format("2006-01-02"), value: v}) + } + return out, nil +} diff --git a/apps/web/src/stores/accounts.ts b/apps/web/src/stores/accounts.ts index 95ae48b..7225602 100644 --- a/apps/web/src/stores/accounts.ts +++ b/apps/web/src/stores/accounts.ts @@ -8,6 +8,8 @@ export interface Account { type: 'checking' | 'savings' | 'investment' | 'credit' initial_balance: number balance: number + yield_type: 'none' | 'cdi' | 'variable' + last_yield_date?: string created_at: string updated_at: string } @@ -16,6 +18,7 @@ export interface AccountInput { name: string type: string initial_balance: number + yield_type: string } export const useAccountsStore = defineStore('accounts', () => { diff --git a/apps/web/src/views/AccountsView.vue b/apps/web/src/views/AccountsView.vue index 0efff24..737968b 100644 --- a/apps/web/src/views/AccountsView.vue +++ b/apps/web/src/views/AccountsView.vue @@ -13,7 +13,7 @@ const typeLabels: Record = { credit: 'Cartão de Crédito', } -const blank = (): AccountInput => ({ name: '', type: 'checking', initial_balance: 0 }) +const blank = (): AccountInput => ({ name: '', type: 'checking', initial_balance: 0, yield_type: 'none' }) const form = ref(blank()) const editId = ref(null) const initialRaw = ref('') @@ -27,7 +27,7 @@ function startEdit(id: number) { const a = store.accounts.find((x) => x.id === id) if (!a) return editId.value = id - form.value = { name: a.name, type: a.type, initial_balance: a.initial_balance } + form.value = { name: a.name, type: a.type, initial_balance: a.initial_balance, yield_type: a.yield_type ?? 'none' } initialRaw.value = a.initial_balance.toLocaleString('pt-BR', { minimumFractionDigits: 2 }) formError.value = null } @@ -85,6 +85,11 @@ function totalBalance() { +
- {{ a.name }} +
+ {{ a.name }} + CDI + VAR +
{{ typeLabels[a.type] }} · inicial {{ fmt(a.initial_balance) }} @@ -165,8 +174,9 @@ function totalBalance() { } .fc-acc-form__name { flex: 1; min-width: 160px; } -.fc-acc-form__type { width: 160px; } -.fc-acc-form__balance { width: 160px; } +.fc-acc-form__type { width: 150px; } +.fc-acc-form__yield { width: 160px; } +.fc-acc-form__balance { width: 150px; } .fc-acc-form__error { color: var(--fc-red); @@ -237,9 +247,16 @@ function totalBalance() { gap: 4px; } +.fc-acc-item__name-row { display: flex; align-items: center; gap: 8px; } .fc-acc-item__name { font-size: 14px; font-weight: 500; } .fc-acc-item__meta { font-size: 11px; color: var(--fc-text-dim); } +.fc-acc-badge { + font-size: 7px; padding: 2px 5px; border-radius: 2px; letter-spacing: .05em; +} +.fc-acc-badge--cdi { background: rgba(34,197,94,.2); color: var(--fc-green); border: 1px solid var(--fc-green); } +.fc-acc-badge--var { background: rgba(251,191,36,.2); color: var(--fc-gold); border: 1px solid var(--fc-gold); } + .fc-acc-item__right { display: flex; align-items: center;