- Erro BCB (dado não publicado): bloqueia retry por 15 min, não até EOD - Sucesso BCB: cache válido até 23:59 como antes - Garante que o CDI do dia seja calculado quando BCB publicar (~9h)
252 lines
7.4 KiB
Go
252 lines
7.4 KiB
Go
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.
|
|
// Dados válidos: expiram às 23:59:59 do dia do fetch.
|
|
// Erro BCB (dado ainda não publicado): retry após 15 minutos.
|
|
type cdiRateCache struct {
|
|
mu sync.RWMutex
|
|
rates map[string]float64 // YYYY-MM-DD → taxa diária
|
|
minDate string
|
|
maxDate string
|
|
expiresAt time.Time // EOD — só setado em sucesso
|
|
retryAfter time.Time // curto prazo — setado em erro BCB
|
|
errorRange [2]string // range que retornou erro
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// throttled retorna true se uma chamada BCB recente falhou e ainda não passou o retry window.
|
|
func (c *cdiRateCache) throttled(startDate, endDate string) bool {
|
|
return !c.retryAfter.IsZero() &&
|
|
time.Now().Before(c.retryAfter) &&
|
|
startDate >= c.errorRange[0] &&
|
|
endDate <= c.errorRange[1]
|
|
}
|
|
|
|
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())
|
|
// limpa throttle ao ter sucesso
|
|
c.retryAfter = time.Time{}
|
|
}
|
|
|
|
func (c *cdiRateCache) markError(startDate, endDate string) {
|
|
c.retryAfter = time.Now().Add(15 * time.Minute)
|
|
c.errorRange = [2]string{startDate, endDate}
|
|
}
|
|
|
|
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 unreachable: %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
|
|
}
|
|
if s.cache.throttled(startDate, endDate) {
|
|
s.cache.mu.RUnlock()
|
|
log.Printf("[cdi] throttled [%s, %s] — retry após %s", startDate, endDate, s.cache.retryAfter.Format("15:04:05"))
|
|
return nil, 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
|
|
}
|
|
if s.cache.throttled(startDate, endDate) {
|
|
return nil, 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 {
|
|
// sem conectividade — não cachear, tentar na próxima request
|
|
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 {
|
|
// BCB retornou objeto (dado ainda não publicado) — retry em 15 min
|
|
log.Printf("[cdi] BCB decode error para [%s, %s]: %v — retry em 15min", startDate, endDate, err)
|
|
s.cache.markError(startDate, endDate)
|
|
return nil, nil
|
|
}
|
|
|
|
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
|
|
}
|