feat(#35): rendimento CDI automático em contas de investimento

- Migration 010: colunas yield_type e last_yield_date em accounts + source='yield' nas transações
- CDIYieldService: busca taxas BCB (serie 12) e cria transação source=yield com rendimento acumulado
- Weekends/feriados sem taxa usam última taxa disponível (AC4)
- GET /api/accounts dispara cálculo CDI on-demand antes de retornar saldos
- AccountsView: seletor yield_type (none/cdi/variable) + badge CDI/VAR na listagem
- Transações source=yield aparecem no histórico com descrição 'Rendimento CDI — {período}'

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
2026-05-27 15:06:05 -03:00
co-authored by Claude Sonnet 4.6
parent 79f4a62a1b
commit ee6cd9f37e
9 changed files with 249 additions and 27 deletions
+19 -3
View File
@@ -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)
}
+12 -5
View File
@@ -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)
+139
View File
@@ -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
}