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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user