Files
carvalho-finances/apps/api/internal/service/cdi.go
T
Mlcavalho1 61981fffcf perf(cdi): cache em memória das taxas BCB com expiração às 23:59 do dia do fetch
- Cache map[date]rate compartilhado entre chamadas do mesmo processo
- Expira às 23:59:59 do dia corrente (não TTL fixo de 24h)
- Double-checked locking evita múltiplas chamadas concorrentes ao BCB
- Cobre múltiplas contas com start dates diferentes sem chamadas redundantes
2026-05-29 00:14:26 -03:00

208 lines
5.3 KiB
Go

package service
import (
"context"
"encoding/json"
"fmt"
"net/http"
"sync"
"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"
// cdiRateCache guarda as taxas BCB em memória até as 23:59:59 do dia do fetch.
type cdiRateCache struct {
mu sync.RWMutex
rates map[string]float64 // YYYY-MM-DD → taxa diária
minDate string
maxDate string
expiresAt time.Time
}
func (c *cdiRateCache) valid() bool {
return !c.expiresAt.IsZero() && time.Now().Before(c.expiresAt)
}
func (c *cdiRateCache) covers(startDate, endDate string) bool {
return c.valid() && c.minDate != "" && startDate >= c.minDate && endDate <= c.maxDate
}
func (c *cdiRateCache) get(startDate, endDate string) []bcbEntry {
start, _ := time.Parse("2006-01-02", startDate)
end, _ := time.Parse("2006-01-02", endDate)
var out []bcbEntry
for d := start; !d.After(end); d = d.AddDate(0, 0, 1) {
key := d.Format("2006-01-02")
if r, ok := c.rates[key]; ok {
out = append(out, bcbEntry{date: key, value: r})
}
}
return out
}
func (c *cdiRateCache) put(entries []bcbEntry, startDate, endDate string) {
if c.rates == nil {
c.rates = make(map[string]float64, len(entries))
}
for _, e := range entries {
c.rates[e.date] = e.value
}
if c.minDate == "" || startDate < c.minDate {
c.minDate = startDate
}
if c.maxDate == "" || endDate > c.maxDate {
c.maxDate = endDate
}
now := time.Now()
c.expiresAt = time.Date(now.Year(), now.Month(), now.Day(), 23, 59, 59, 0, now.Location())
}
type CDIYieldService struct {
accountRepo repository.AccountRepoWithYield
txRepo repository.ManualTransactionRepository
client *http.Client
cache cdiRateCache
}
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
}
cdiPct := a.CDIPercentage
if cdiPct <= 0 {
cdiPct = 100
}
yieldAmount := a.Balance * (compound - 1) * (cdiPct / 100)
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) {
s.cache.mu.RLock()
if s.cache.covers(startDate, endDate) {
entries := s.cache.get(startDate, endDate)
s.cache.mu.RUnlock()
return entries, nil
}
s.cache.mu.RUnlock()
s.cache.mu.Lock()
defer s.cache.mu.Unlock()
// double-check after acquiring write lock
if s.cache.covers(startDate, endDate) {
return s.cache.get(startDate, endDate), nil
}
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 entries []bcbEntry
for _, r := range raw {
t, err := time.Parse("02/01/2006", r.Data)
if err != nil {
continue
}
var v float64
fmt.Sscanf(r.Valor, "%f", &v)
entries = append(entries, bcbEntry{date: t.Format("2006-01-02"), value: v})
}
s.cache.put(entries, startDate, endDate)
return entries, nil
}