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:
@@ -70,7 +70,8 @@ func main() {
|
|||||||
recurringHandler := handler.NewRecurringHandler(recurringSvc)
|
recurringHandler := handler.NewRecurringHandler(recurringSvc)
|
||||||
|
|
||||||
accountRepo := repository.NewAccountRepository(pool)
|
accountRepo := repository.NewAccountRepository(pool)
|
||||||
accountSvc := service.NewAccountService(accountRepo)
|
cdiSvc := service.NewCDIYieldService(accountRepo, manualTxRepo)
|
||||||
|
accountSvc := service.NewAccountService(accountRepo, cdiSvc)
|
||||||
accountHandler := handler.NewAccountHandler(accountSvc)
|
accountHandler := handler.NewAccountHandler(accountSvc)
|
||||||
|
|
||||||
dashboardRepo := repository.NewDashboardRepository(pool)
|
dashboardRepo := repository.NewDashboardRepository(pool)
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-- Add CDI yield support to accounts
|
||||||
|
ALTER TABLE accounts
|
||||||
|
ADD COLUMN IF NOT EXISTS yield_type VARCHAR(10) NOT NULL DEFAULT 'none'
|
||||||
|
CHECK (yield_type IN ('none', 'cdi', 'variable')),
|
||||||
|
ADD COLUMN IF NOT EXISTS last_yield_date DATE;
|
||||||
|
|
||||||
|
-- Extend transactions.source check to allow 'yield'
|
||||||
|
ALTER TABLE transactions DROP CONSTRAINT IF EXISTS transactions_source_check;
|
||||||
|
ALTER TABLE transactions
|
||||||
|
ADD CONSTRAINT transactions_source_check
|
||||||
|
CHECK (source IN ('manual', 'import', 'yield'));
|
||||||
@@ -6,6 +6,8 @@ type Account struct {
|
|||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
InitialBalance float64 `json:"initial_balance"`
|
InitialBalance float64 `json:"initial_balance"`
|
||||||
Balance float64 `json:"balance"`
|
Balance float64 `json:"balance"`
|
||||||
|
YieldType string `json:"yield_type"` // "none" | "cdi" | "variable"
|
||||||
|
LastYieldDate *string `json:"last_yield_date,omitempty"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
UpdatedAt string `json:"updated_at"`
|
UpdatedAt string `json:"updated_at"`
|
||||||
}
|
}
|
||||||
@@ -14,4 +16,5 @@ type AccountInput struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
InitialBalance float64 `json:"initial_balance"`
|
InitialBalance float64 `json:"initial_balance"`
|
||||||
|
YieldType string `json:"yield_type"` // "none" | "cdi" | "variable"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,17 @@ import (
|
|||||||
"financeiro-carvalho/internal/model"
|
"financeiro-carvalho/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// AccountRepoWithYield extends the basic account repo with CDI-related operations.
|
||||||
|
type AccountRepoWithYield interface {
|
||||||
|
List(ctx context.Context) ([]model.Account, error)
|
||||||
|
GetByID(ctx context.Context, id int) (*model.Account, error)
|
||||||
|
Create(ctx context.Context, in model.AccountInput) (*model.Account, error)
|
||||||
|
Update(ctx context.Context, id int, in model.AccountInput) (*model.Account, error)
|
||||||
|
Delete(ctx context.Context, id int) error
|
||||||
|
TotalPatrimony(ctx context.Context) (float64, error)
|
||||||
|
UpdateLastYieldDate(ctx context.Context, id int, date string) error
|
||||||
|
}
|
||||||
|
|
||||||
type AccountRepository struct {
|
type AccountRepository struct {
|
||||||
pool *pgxpool.Pool
|
pool *pgxpool.Pool
|
||||||
}
|
}
|
||||||
@@ -20,6 +31,7 @@ func (r *AccountRepository) List(ctx context.Context) ([]model.Account, error) {
|
|||||||
rows, err := r.pool.Query(ctx, `
|
rows, err := r.pool.Query(ctx, `
|
||||||
SELECT
|
SELECT
|
||||||
a.id, a.name, a.type, a.initial_balance,
|
a.id, a.name, a.type, a.initial_balance,
|
||||||
|
a.yield_type, a.last_yield_date::text,
|
||||||
a.created_at::text, a.updated_at::text,
|
a.created_at::text, a.updated_at::text,
|
||||||
a.initial_balance
|
a.initial_balance
|
||||||
+ COALESCE(SUM(CASE WHEN t.type = 'income' THEN t.amount ELSE 0 END), 0)
|
+ COALESCE(SUM(CASE WHEN t.type = 'income' THEN t.amount ELSE 0 END), 0)
|
||||||
@@ -38,7 +50,7 @@ func (r *AccountRepository) List(ctx context.Context) ([]model.Account, error) {
|
|||||||
var out []model.Account
|
var out []model.Account
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var a model.Account
|
var a model.Account
|
||||||
if err := rows.Scan(&a.ID, &a.Name, &a.Type, &a.InitialBalance, &a.CreatedAt, &a.UpdatedAt, &a.Balance); err != nil {
|
if err := rows.Scan(&a.ID, &a.Name, &a.Type, &a.InitialBalance, &a.YieldType, &a.LastYieldDate, &a.CreatedAt, &a.UpdatedAt, &a.Balance); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
out = append(out, a)
|
out = append(out, a)
|
||||||
@@ -51,6 +63,7 @@ func (r *AccountRepository) GetByID(ctx context.Context, id int) (*model.Account
|
|||||||
err := r.pool.QueryRow(ctx, `
|
err := r.pool.QueryRow(ctx, `
|
||||||
SELECT
|
SELECT
|
||||||
a.id, a.name, a.type, a.initial_balance,
|
a.id, a.name, a.type, a.initial_balance,
|
||||||
|
a.yield_type, a.last_yield_date::text,
|
||||||
a.created_at::text, a.updated_at::text,
|
a.created_at::text, a.updated_at::text,
|
||||||
a.initial_balance
|
a.initial_balance
|
||||||
+ COALESCE(SUM(CASE WHEN t.type = 'income' THEN t.amount ELSE 0 END), 0)
|
+ COALESCE(SUM(CASE WHEN t.type = 'income' THEN t.amount ELSE 0 END), 0)
|
||||||
@@ -60,7 +73,7 @@ func (r *AccountRepository) GetByID(ctx context.Context, id int) (*model.Account
|
|||||||
LEFT JOIN transactions t ON t.account_id = a.id
|
LEFT JOIN transactions t ON t.account_id = a.id
|
||||||
WHERE a.id = $1
|
WHERE a.id = $1
|
||||||
GROUP BY a.id
|
GROUP BY a.id
|
||||||
`, id).Scan(&a.ID, &a.Name, &a.Type, &a.InitialBalance, &a.CreatedAt, &a.UpdatedAt, &a.Balance)
|
`, id).Scan(&a.ID, &a.Name, &a.Type, &a.InitialBalance, &a.YieldType, &a.LastYieldDate, &a.CreatedAt, &a.UpdatedAt, &a.Balance)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -68,13 +81,17 @@ func (r *AccountRepository) GetByID(ctx context.Context, id int) (*model.Account
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *AccountRepository) Create(ctx context.Context, in model.AccountInput) (*model.Account, error) {
|
func (r *AccountRepository) Create(ctx context.Context, in model.AccountInput) (*model.Account, error) {
|
||||||
|
yt := in.YieldType
|
||||||
|
if yt == "" {
|
||||||
|
yt = "none"
|
||||||
|
}
|
||||||
var a model.Account
|
var a model.Account
|
||||||
err := r.pool.QueryRow(ctx, `
|
err := r.pool.QueryRow(ctx, `
|
||||||
INSERT INTO accounts (name, type, initial_balance)
|
INSERT INTO accounts (name, type, initial_balance, yield_type)
|
||||||
VALUES ($1, $2, $3)
|
VALUES ($1, $2, $3, $4)
|
||||||
RETURNING id, name, type, initial_balance, created_at::text, updated_at::text
|
RETURNING id, name, type, initial_balance, yield_type, last_yield_date::text, created_at::text, updated_at::text
|
||||||
`, in.Name, in.Type, in.InitialBalance).
|
`, in.Name, in.Type, in.InitialBalance, yt).
|
||||||
Scan(&a.ID, &a.Name, &a.Type, &a.InitialBalance, &a.CreatedAt, &a.UpdatedAt)
|
Scan(&a.ID, &a.Name, &a.Type, &a.InitialBalance, &a.YieldType, &a.LastYieldDate, &a.CreatedAt, &a.UpdatedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -83,16 +100,19 @@ func (r *AccountRepository) Create(ctx context.Context, in model.AccountInput) (
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *AccountRepository) Update(ctx context.Context, id int, in model.AccountInput) (*model.Account, error) {
|
func (r *AccountRepository) Update(ctx context.Context, id int, in model.AccountInput) (*model.Account, error) {
|
||||||
|
yt := in.YieldType
|
||||||
|
if yt == "" {
|
||||||
|
yt = "none"
|
||||||
|
}
|
||||||
row := r.pool.QueryRow(ctx, `
|
row := r.pool.QueryRow(ctx, `
|
||||||
UPDATE accounts SET name=$1, type=$2, initial_balance=$3, updated_at=NOW()
|
UPDATE accounts SET name=$1, type=$2, initial_balance=$3, yield_type=$4, updated_at=NOW()
|
||||||
WHERE id=$4
|
WHERE id=$5
|
||||||
RETURNING id, name, type, initial_balance, created_at::text, updated_at::text
|
RETURNING id, name, type, initial_balance, yield_type, last_yield_date::text, created_at::text, updated_at::text
|
||||||
`, in.Name, in.Type, in.InitialBalance, id)
|
`, in.Name, in.Type, in.InitialBalance, yt, id)
|
||||||
var a model.Account
|
var a model.Account
|
||||||
if err := row.Scan(&a.ID, &a.Name, &a.Type, &a.InitialBalance, &a.CreatedAt, &a.UpdatedAt); err != nil {
|
if err := row.Scan(&a.ID, &a.Name, &a.Type, &a.InitialBalance, &a.YieldType, &a.LastYieldDate, &a.CreatedAt, &a.UpdatedAt); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
// recalculate balance
|
|
||||||
full, err := r.GetByID(ctx, a.ID)
|
full, err := r.GetByID(ctx, a.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -119,3 +139,8 @@ func (r *AccountRepository) TotalPatrimony(ctx context.Context) (float64, error)
|
|||||||
`).Scan(&total)
|
`).Scan(&total)
|
||||||
return total, err
|
return total, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *AccountRepository) UpdateLastYieldDate(ctx context.Context, id int, date string) error {
|
||||||
|
_, err := r.pool.Exec(ctx, `UPDATE accounts SET last_yield_date = $1 WHERE id = $2`, date, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
|
|
||||||
"financeiro-carvalho/internal/model"
|
"financeiro-carvalho/internal/model"
|
||||||
|
"financeiro-carvalho/internal/repository"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -26,11 +27,12 @@ type AccountRepo interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type AccountService struct {
|
type AccountService struct {
|
||||||
repo AccountRepo
|
repo repository.AccountRepoWithYield
|
||||||
|
cdiSvc *CDIYieldService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAccountService(repo AccountRepo) *AccountService {
|
func NewAccountService(repo repository.AccountRepoWithYield, cdiSvc *CDIYieldService) *AccountService {
|
||||||
return &AccountService{repo: repo}
|
return &AccountService{repo: repo, cdiSvc: cdiSvc}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *AccountService) validate(in model.AccountInput) error {
|
func (s *AccountService) validate(in model.AccountInput) error {
|
||||||
@@ -43,7 +45,21 @@ func (s *AccountService) validate(in model.AccountInput) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// List returns accounts, applying CDI yield on-demand for cdi-type accounts.
|
||||||
func (s *AccountService) List(ctx context.Context) ([]model.Account, error) {
|
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)
|
return s.repo.List(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,9 +39,16 @@ func (m *mockAccountRepo) Update(_ context.Context, id int, in model.AccountInpu
|
|||||||
}
|
}
|
||||||
func (m *mockAccountRepo) Delete(_ context.Context, _ int) error { return 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) 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) {
|
func TestCreateAccount_EmptyName(t *testing.T) {
|
||||||
svc := service.NewAccountService(&mockAccountRepo{})
|
svc := newAccountSvc()
|
||||||
_, err := svc.Create(context.Background(), model.AccountInput{Name: "", Type: "checking", InitialBalance: 0})
|
_, err := svc.Create(context.Background(), model.AccountInput{Name: "", Type: "checking", InitialBalance: 0})
|
||||||
if err != service.ErrAccountEmptyName {
|
if err != service.ErrAccountEmptyName {
|
||||||
t.Fatalf("expected ErrAccountEmptyName, got %v", err)
|
t.Fatalf("expected ErrAccountEmptyName, got %v", err)
|
||||||
@@ -49,7 +56,7 @@ func TestCreateAccount_EmptyName(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCreateAccount_InvalidType(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"})
|
_, err := svc.Create(context.Background(), model.AccountInput{Name: "Nubank", Type: "bitcoin"})
|
||||||
if err != service.ErrAccountInvalidType {
|
if err != service.ErrAccountInvalidType {
|
||||||
t.Fatalf("expected ErrAccountInvalidType, got %v", err)
|
t.Fatalf("expected ErrAccountInvalidType, got %v", err)
|
||||||
@@ -57,7 +64,7 @@ func TestCreateAccount_InvalidType(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCreateAccount_OK(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})
|
a, err := svc.Create(context.Background(), model.AccountInput{Name: "Nubank", Type: "checking", InitialBalance: 1000})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -8,6 +8,8 @@ export interface Account {
|
|||||||
type: 'checking' | 'savings' | 'investment' | 'credit'
|
type: 'checking' | 'savings' | 'investment' | 'credit'
|
||||||
initial_balance: number
|
initial_balance: number
|
||||||
balance: number
|
balance: number
|
||||||
|
yield_type: 'none' | 'cdi' | 'variable'
|
||||||
|
last_yield_date?: string
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
}
|
}
|
||||||
@@ -16,6 +18,7 @@ export interface AccountInput {
|
|||||||
name: string
|
name: string
|
||||||
type: string
|
type: string
|
||||||
initial_balance: number
|
initial_balance: number
|
||||||
|
yield_type: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useAccountsStore = defineStore('accounts', () => {
|
export const useAccountsStore = defineStore('accounts', () => {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const typeLabels: Record<string, string> = {
|
|||||||
credit: 'Cartão de Crédito',
|
credit: 'Cartão de Crédito',
|
||||||
}
|
}
|
||||||
|
|
||||||
const blank = (): AccountInput => ({ name: '', type: 'checking', initial_balance: 0 })
|
const blank = (): AccountInput => ({ name: '', type: 'checking', initial_balance: 0, yield_type: 'none' })
|
||||||
const form = ref(blank())
|
const form = ref(blank())
|
||||||
const editId = ref<number | null>(null)
|
const editId = ref<number | null>(null)
|
||||||
const initialRaw = ref('')
|
const initialRaw = ref('')
|
||||||
@@ -27,7 +27,7 @@ function startEdit(id: number) {
|
|||||||
const a = store.accounts.find((x) => x.id === id)
|
const a = store.accounts.find((x) => x.id === id)
|
||||||
if (!a) return
|
if (!a) return
|
||||||
editId.value = id
|
editId.value = id
|
||||||
form.value = { name: a.name, type: a.type, initial_balance: a.initial_balance }
|
form.value = { name: a.name, type: a.type, initial_balance: a.initial_balance, yield_type: a.yield_type ?? 'none' }
|
||||||
initialRaw.value = a.initial_balance.toLocaleString('pt-BR', { minimumFractionDigits: 2 })
|
initialRaw.value = a.initial_balance.toLocaleString('pt-BR', { minimumFractionDigits: 2 })
|
||||||
formError.value = null
|
formError.value = null
|
||||||
}
|
}
|
||||||
@@ -85,6 +85,11 @@ function totalBalance() {
|
|||||||
<option value="investment">Investimento</option>
|
<option value="investment">Investimento</option>
|
||||||
<option value="credit">Cartão de Crédito</option>
|
<option value="credit">Cartão de Crédito</option>
|
||||||
</select>
|
</select>
|
||||||
|
<select v-model="form.yield_type" class="fc-select fc-acc-form__yield">
|
||||||
|
<option value="none">Sem rendimento</option>
|
||||||
|
<option value="cdi">CDI automático</option>
|
||||||
|
<option value="variable">Renda variável</option>
|
||||||
|
</select>
|
||||||
<input
|
<input
|
||||||
v-model="initialRaw"
|
v-model="initialRaw"
|
||||||
placeholder="Saldo inicial (ex: 1.500,00)"
|
placeholder="Saldo inicial (ex: 1.500,00)"
|
||||||
@@ -120,7 +125,11 @@ function totalBalance() {
|
|||||||
:class="{ 'fc-acc-item--editing': editId === a.id }"
|
:class="{ 'fc-acc-item--editing': editId === a.id }"
|
||||||
>
|
>
|
||||||
<div class="fc-acc-item__info">
|
<div class="fc-acc-item__info">
|
||||||
|
<div class="fc-acc-item__name-row">
|
||||||
<span class="fc-body fc-acc-item__name">{{ a.name }}</span>
|
<span class="fc-body fc-acc-item__name">{{ a.name }}</span>
|
||||||
|
<span v-if="a.yield_type === 'cdi'" class="fc-acc-badge fc-acc-badge--cdi fc-pixel">CDI</span>
|
||||||
|
<span v-else-if="a.yield_type === 'variable'" class="fc-acc-badge fc-acc-badge--var fc-pixel">VAR</span>
|
||||||
|
</div>
|
||||||
<span class="fc-mono fc-acc-item__meta">
|
<span class="fc-mono fc-acc-item__meta">
|
||||||
{{ typeLabels[a.type] }} · inicial {{ fmt(a.initial_balance) }}
|
{{ typeLabels[a.type] }} · inicial {{ fmt(a.initial_balance) }}
|
||||||
</span>
|
</span>
|
||||||
@@ -165,8 +174,9 @@ function totalBalance() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.fc-acc-form__name { flex: 1; min-width: 160px; }
|
.fc-acc-form__name { flex: 1; min-width: 160px; }
|
||||||
.fc-acc-form__type { width: 160px; }
|
.fc-acc-form__type { width: 150px; }
|
||||||
.fc-acc-form__balance { width: 160px; }
|
.fc-acc-form__yield { width: 160px; }
|
||||||
|
.fc-acc-form__balance { width: 150px; }
|
||||||
|
|
||||||
.fc-acc-form__error {
|
.fc-acc-form__error {
|
||||||
color: var(--fc-red);
|
color: var(--fc-red);
|
||||||
@@ -237,9 +247,16 @@ function totalBalance() {
|
|||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.fc-acc-item__name-row { display: flex; align-items: center; gap: 8px; }
|
||||||
.fc-acc-item__name { font-size: 14px; font-weight: 500; }
|
.fc-acc-item__name { font-size: 14px; font-weight: 500; }
|
||||||
.fc-acc-item__meta { font-size: 11px; color: var(--fc-text-dim); }
|
.fc-acc-item__meta { font-size: 11px; color: var(--fc-text-dim); }
|
||||||
|
|
||||||
|
.fc-acc-badge {
|
||||||
|
font-size: 7px; padding: 2px 5px; border-radius: 2px; letter-spacing: .05em;
|
||||||
|
}
|
||||||
|
.fc-acc-badge--cdi { background: rgba(34,197,94,.2); color: var(--fc-green); border: 1px solid var(--fc-green); }
|
||||||
|
.fc-acc-badge--var { background: rgba(251,191,36,.2); color: var(--fc-gold); border: 1px solid var(--fc-gold); }
|
||||||
|
|
||||||
.fc-acc-item__right {
|
.fc-acc-item__right {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
Reference in New Issue
Block a user