package service import ( "context" "encoding/json" "fmt" "log" "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") } log.Printf("[cdi] account=%d lastYieldDate=%v startDate=%s yesterday=%s", a.ID, a.LastYieldDate, startDate, yesterday) if startDate > yesterday { log.Printf("[cdi] account=%d early return (already up to date)", a.ID) return nil } log.Printf("[cdi] account=%d calling BCB for [%s, %s]", a.ID, startDate, yesterday) rates, err := s.fetchCDIRates(startDate, yesterday) if err != nil { log.Printf("[cdi] account=%d BCB error: %v", a.ID, err) return nil } log.Printf("[cdi] account=%d BCB returned %d rates", a.ID, len(rates)) 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 { log.Printf("[cdi] account=%d txRepo.Create error: %v", a.ID, err) return err } if err := s.accountRepo.UpdateLastYieldDate(ctx, a.ID, yesterday); err != nil { log.Printf("[cdi] account=%d UpdateLastYieldDate error: %v", a.ID, err) return err } log.Printf("[cdi] account=%d yield applied OK, lastYieldDate → %s", a.ID, yesterday) return nil } 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() log.Printf("[cdi] cache HIT [%s, %s] → %d entries", startDate, endDate, len(entries)) return entries, nil } log.Printf("[cdi] cache MISS [%s, %s] (minDate=%s maxDate=%s valid=%v)", startDate, endDate, s.cache.minDate, s.cache.maxDate, s.cache.valid()) 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 }