From 62928589707d46a7dcd5d5bbb26baf836e0a98e4 Mon Sep 17 00:00:00 2001 From: Mlcarvalho1 Date: Thu, 28 May 2026 22:20:32 -0300 Subject: [PATCH] =?UTF-8?q?feat:=20CDI=20%=20configur=C3=A1vel=20por=20con?= =?UTF-8?q?ta=20+=20fix=20mocks=20de=20teste?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Adiciona campo cdi_percentage (default 100%) na tabela accounts - Cálculo CDI aplica proporção: balance × (compound-1) × (pct/100) - Frontend exibe input de % quando yield_type=cdi e badge mostra valor - Corrige mocks de teste desatualizados (DeleteByMonth, isTax) - Corrige ConfirmIncome para rejeitar tipo expense (ErrRecurringNotIncome) Co-Authored-By: Claude Sonnet 4.6 --- apps/api/internal/migration/migration.go | 5 ++- .../sql/015_accounts_cdi_percentage.sql | 2 ++ apps/api/internal/model/account.go | 26 +++++++------- apps/api/internal/repository/account.go | 36 +++++++++++-------- apps/api/internal/service/category_test.go | 4 +-- apps/api/internal/service/cdi.go | 6 +++- apps/api/internal/service/recurring.go | 8 ++--- apps/api/internal/service/recurring_test.go | 3 +- apps/web/src/stores/accounts.ts | 2 ++ apps/web/src/views/AccountsView.vue | 18 ++++++++-- 10 files changed, 73 insertions(+), 37 deletions(-) create mode 100644 apps/api/internal/migration/sql/015_accounts_cdi_percentage.sql diff --git a/apps/api/internal/migration/migration.go b/apps/api/internal/migration/migration.go index d128868..019a39b 100644 --- a/apps/api/internal/migration/migration.go +++ b/apps/api/internal/migration/migration.go @@ -50,6 +50,9 @@ var m013 string //go:embed sql/014_profiles.sql var m014 string +//go:embed sql/015_accounts_cdi_percentage.sql +var m015 string + func Run(ctx context.Context, pool *pgxpool.Pool) error { // Bootstrap: ensure schema_migrations table exists before checking versions. if _, err := pool.Exec(ctx, ` @@ -61,7 +64,7 @@ func Run(ctx context.Context, pool *pgxpool.Pool) error { return fmt.Errorf("bootstrap schema_migrations: %w", err) } - migrations := []string{m001, m002, m003, m004, m005, m006, m007, m008, m009, m010, m011, m012, m013, m014} + migrations := []string{m001, m002, m003, m004, m005, m006, m007, m008, m009, m010, m011, m012, m013, m014, m015} for i, sql := range migrations { version := i + 1 var applied bool diff --git a/apps/api/internal/migration/sql/015_accounts_cdi_percentage.sql b/apps/api/internal/migration/sql/015_accounts_cdi_percentage.sql new file mode 100644 index 0000000..7c93a97 --- /dev/null +++ b/apps/api/internal/migration/sql/015_accounts_cdi_percentage.sql @@ -0,0 +1,2 @@ +ALTER TABLE accounts + ADD COLUMN IF NOT EXISTS cdi_percentage NUMERIC(5,2) NOT NULL DEFAULT 100; diff --git a/apps/api/internal/model/account.go b/apps/api/internal/model/account.go index 9189011..4d0a4d5 100644 --- a/apps/api/internal/model/account.go +++ b/apps/api/internal/model/account.go @@ -1,25 +1,27 @@ package model type Account struct { - ID int `json:"id"` - Name string `json:"name"` - Type string `json:"type"` - InitialBalance float64 `json:"initial_balance"` - Balance float64 `json:"balance"` - YieldType string `json:"yield_type"` // "none" | "cdi" | "variable" - LastYieldDate *string `json:"last_yield_date,omitempty"` - ClosingDay *int `json:"closing_day,omitempty"` - DueDay *int `json:"due_day,omitempty"` + ID int `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + InitialBalance float64 `json:"initial_balance"` + Balance float64 `json:"balance"` + YieldType string `json:"yield_type"` // "none" | "cdi" | "variable" + CDIPercentage float64 `json:"cdi_percentage"` // % do CDI que a conta rende (default 100) + LastYieldDate *string `json:"last_yield_date,omitempty"` + ClosingDay *int `json:"closing_day,omitempty"` + DueDay *int `json:"due_day,omitempty"` CurrentBill *CreditBill `json:"current_bill,omitempty"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` } type AccountInput struct { Name string `json:"name"` Type string `json:"type"` InitialBalance float64 `json:"initial_balance"` - YieldType string `json:"yield_type"` // "none" | "cdi" | "variable" + YieldType string `json:"yield_type"` // "none" | "cdi" | "variable" + CDIPercentage float64 `json:"cdi_percentage"` // % do CDI (default 100) ClosingDay *int `json:"closing_day,omitempty"` DueDay *int `json:"due_day,omitempty"` } diff --git a/apps/api/internal/repository/account.go b/apps/api/internal/repository/account.go index ea9d556..6bbc1e1 100644 --- a/apps/api/internal/repository/account.go +++ b/apps/api/internal/repository/account.go @@ -33,7 +33,7 @@ func (r *AccountRepository) List(ctx context.Context) ([]model.Account, error) { rows, err := r.pool.Query(ctx, ` SELECT a.id, a.name, a.type, a.initial_balance, - a.yield_type, a.last_yield_date::text, + a.yield_type, a.cdi_percentage, a.last_yield_date::text, a.closing_day, a.due_day, a.created_at::text, a.updated_at::text, a.initial_balance @@ -54,7 +54,7 @@ func (r *AccountRepository) List(ctx context.Context) ([]model.Account, error) { var out []model.Account for rows.Next() { var a model.Account - if err := rows.Scan(&a.ID, &a.Name, &a.Type, &a.InitialBalance, &a.YieldType, &a.LastYieldDate, &a.ClosingDay, &a.DueDay, &a.CreatedAt, &a.UpdatedAt, &a.Balance); err != nil { + if err := rows.Scan(&a.ID, &a.Name, &a.Type, &a.InitialBalance, &a.YieldType, &a.CDIPercentage, &a.LastYieldDate, &a.ClosingDay, &a.DueDay, &a.CreatedAt, &a.UpdatedAt, &a.Balance); err != nil { return nil, err } out = append(out, a) @@ -68,7 +68,7 @@ func (r *AccountRepository) GetByID(ctx context.Context, id int) (*model.Account err := r.pool.QueryRow(ctx, ` SELECT a.id, a.name, a.type, a.initial_balance, - a.yield_type, a.last_yield_date::text, + a.yield_type, a.cdi_percentage, a.last_yield_date::text, a.closing_day, a.due_day, a.created_at::text, a.updated_at::text, a.initial_balance @@ -79,7 +79,7 @@ func (r *AccountRepository) GetByID(ctx context.Context, id int) (*model.Account LEFT JOIN transactions t ON t.account_id = a.id WHERE a.id = $1 AND a.profile_id = $2 GROUP BY a.id - `, id, pid).Scan(&a.ID, &a.Name, &a.Type, &a.InitialBalance, &a.YieldType, &a.LastYieldDate, &a.ClosingDay, &a.DueDay, &a.CreatedAt, &a.UpdatedAt, &a.Balance) + `, id, pid).Scan(&a.ID, &a.Name, &a.Type, &a.InitialBalance, &a.YieldType, &a.CDIPercentage, &a.LastYieldDate, &a.ClosingDay, &a.DueDay, &a.CreatedAt, &a.UpdatedAt, &a.Balance) if err != nil { return nil, err } @@ -92,13 +92,17 @@ func (r *AccountRepository) Create(ctx context.Context, in model.AccountInput) ( if yt == "" { yt = "none" } + cdiPct := in.CDIPercentage + if cdiPct <= 0 { + cdiPct = 100 + } var a model.Account err := r.pool.QueryRow(ctx, ` - INSERT INTO accounts (name, type, initial_balance, yield_type, closing_day, due_day, profile_id) - VALUES ($1, $2, $3, $4, $5, $6, $7) - RETURNING id, name, type, initial_balance, yield_type, last_yield_date::text, closing_day, due_day, created_at::text, updated_at::text - `, in.Name, in.Type, in.InitialBalance, yt, in.ClosingDay, in.DueDay, pid). - Scan(&a.ID, &a.Name, &a.Type, &a.InitialBalance, &a.YieldType, &a.LastYieldDate, &a.ClosingDay, &a.DueDay, &a.CreatedAt, &a.UpdatedAt) + INSERT INTO accounts (name, type, initial_balance, yield_type, cdi_percentage, closing_day, due_day, profile_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING id, name, type, initial_balance, yield_type, cdi_percentage, last_yield_date::text, closing_day, due_day, created_at::text, updated_at::text + `, in.Name, in.Type, in.InitialBalance, yt, cdiPct, in.ClosingDay, in.DueDay, pid). + Scan(&a.ID, &a.Name, &a.Type, &a.InitialBalance, &a.YieldType, &a.CDIPercentage, &a.LastYieldDate, &a.ClosingDay, &a.DueDay, &a.CreatedAt, &a.UpdatedAt) if err != nil { return nil, err } @@ -112,13 +116,17 @@ func (r *AccountRepository) Update(ctx context.Context, id int, in model.Account if yt == "" { yt = "none" } + cdiPct := in.CDIPercentage + if cdiPct <= 0 { + cdiPct = 100 + } row := r.pool.QueryRow(ctx, ` - UPDATE accounts SET name=$1, type=$2, initial_balance=$3, yield_type=$4, closing_day=$5, due_day=$6, updated_at=NOW() - WHERE id=$7 AND profile_id=$8 - RETURNING id, name, type, initial_balance, yield_type, last_yield_date::text, closing_day, due_day, created_at::text, updated_at::text - `, in.Name, in.Type, in.InitialBalance, yt, in.ClosingDay, in.DueDay, id, pid) + UPDATE accounts SET name=$1, type=$2, initial_balance=$3, yield_type=$4, cdi_percentage=$5, closing_day=$6, due_day=$7, updated_at=NOW() + WHERE id=$8 AND profile_id=$9 + RETURNING id, name, type, initial_balance, yield_type, cdi_percentage, last_yield_date::text, closing_day, due_day, created_at::text, updated_at::text + `, in.Name, in.Type, in.InitialBalance, yt, cdiPct, in.ClosingDay, in.DueDay, id, pid) var a model.Account - if err := row.Scan(&a.ID, &a.Name, &a.Type, &a.InitialBalance, &a.YieldType, &a.LastYieldDate, &a.ClosingDay, &a.DueDay, &a.CreatedAt, &a.UpdatedAt); err != nil { + if err := row.Scan(&a.ID, &a.Name, &a.Type, &a.InitialBalance, &a.YieldType, &a.CDIPercentage, &a.LastYieldDate, &a.ClosingDay, &a.DueDay, &a.CreatedAt, &a.UpdatedAt); err != nil { return nil, err } full, err := r.GetByID(ctx, a.ID) diff --git a/apps/api/internal/service/category_test.go b/apps/api/internal/service/category_test.go index 27ab14e..865dccc 100644 --- a/apps/api/internal/service/category_test.go +++ b/apps/api/internal/service/category_test.go @@ -33,13 +33,13 @@ func (m *mockCategoryRepo) GetByID(_ context.Context, id int) (*model.Category, return nil, repository.ErrNotFound } -func (m *mockCategoryRepo) Create(_ context.Context, name, color string) (*model.Category, error) { +func (m *mockCategoryRepo) Create(_ context.Context, name, color string, _ bool) (*model.Category, error) { c := model.Category{ID: len(m.categories) + 1, Name: name, Color: color} m.categories = append(m.categories, c) return &c, nil } -func (m *mockCategoryRepo) Update(_ context.Context, id int, name, color string) (*model.Category, error) { +func (m *mockCategoryRepo) Update(_ context.Context, id int, name, color string, _ bool) (*model.Category, error) { for i, c := range m.categories { if c.ID == id { m.categories[i].Name = name diff --git a/apps/api/internal/service/cdi.go b/apps/api/internal/service/cdi.go index 72f640b..c8db2ae 100644 --- a/apps/api/internal/service/cdi.go +++ b/apps/api/internal/service/cdi.go @@ -78,7 +78,11 @@ func (s *CDIYieldService) ApplyYield(ctx context.Context, a *model.Account) erro compound *= 1 + lastRate/100 } - yieldAmount := a.Balance * (compound - 1) + cdiPct := a.CDIPercentage + if cdiPct <= 0 { + cdiPct = 100 + } + yieldAmount := a.Balance * (compound - 1) * (cdiPct / 100) if yieldAmount <= 0 { return nil } diff --git a/apps/api/internal/service/recurring.go b/apps/api/internal/service/recurring.go index ad74786..c8c4844 100644 --- a/apps/api/internal/service/recurring.go +++ b/apps/api/internal/service/recurring.go @@ -110,16 +110,16 @@ func (s *RecurringService) MonthlyStatus(ctx context.Context, month string) ([]m return result, nil } -// ConfirmIncome creates a transaction (income or expense) confirming this recurring item for the month. +// ConfirmIncome creates a transaction confirming an income recurring item for the month. func (s *RecurringService) ConfirmIncome(ctx context.Context, id int, month string) error { re, err := s.repo.GetByID(ctx, id) if err != nil { return err } - txType := re.Type - if txType == "" { - txType = "expense" + if re.Type != "income" { + return ErrRecurringNotIncome } + txType := re.Type today := time.Now().Format("2006-01-02") tx := model.Transaction{ Date: today, diff --git a/apps/api/internal/service/recurring_test.go b/apps/api/internal/service/recurring_test.go index c2009c1..3327207 100644 --- a/apps/api/internal/service/recurring_test.go +++ b/apps/api/internal/service/recurring_test.go @@ -89,7 +89,8 @@ func (m *mockTxRepo) Create(_ context.Context, t model.Transaction) (*model.Tran func (m *mockTxRepo) Update(_ context.Context, t model.Transaction) (*model.Transaction, error) { return &t, nil } -func (m *mockTxRepo) Delete(_ context.Context, _ int) error { return nil } +func (m *mockTxRepo) Delete(_ context.Context, _ int) error { return nil } +func (m *mockTxRepo) DeleteByMonth(_ context.Context, _ string) (int, error) { return 0, nil } func (m *mockTxRepo) HasMatchingTransaction(_ context.Context, _ *int, _ string, _ float64, _ string) (bool, error) { return m.matchResult, nil } diff --git a/apps/web/src/stores/accounts.ts b/apps/web/src/stores/accounts.ts index bb9632d..c84f6a3 100644 --- a/apps/web/src/stores/accounts.ts +++ b/apps/web/src/stores/accounts.ts @@ -22,6 +22,7 @@ export interface Account { initial_balance: number balance: number yield_type: 'none' | 'cdi' | 'variable' + cdi_percentage: number last_yield_date?: string closing_day?: number due_day?: number @@ -35,6 +36,7 @@ export interface AccountInput { type: string initial_balance: number yield_type: string + cdi_percentage: number closing_day?: number | null due_day?: number | null } diff --git a/apps/web/src/views/AccountsView.vue b/apps/web/src/views/AccountsView.vue index 7a582f7..483e894 100644 --- a/apps/web/src/views/AccountsView.vue +++ b/apps/web/src/views/AccountsView.vue @@ -14,7 +14,7 @@ const typeLabels: Record = { credit: 'Cartão de Crédito', } -const blank = (): AccountInput => ({ name: '', type: 'checking', initial_balance: 0, yield_type: 'none', closing_day: null, due_day: null }) +const blank = (): AccountInput => ({ name: '', type: 'checking', initial_balance: 0, yield_type: 'none', cdi_percentage: 100, closing_day: null, due_day: null }) const form = ref(blank()) const editId = ref(null) const formError = ref(null) @@ -26,6 +26,7 @@ function startEdit(id: number) { form.value = { name: a.name, type: a.type, initial_balance: a.initial_balance, yield_type: a.yield_type ?? 'none', + cdi_percentage: a.cdi_percentage ?? 100, closing_day: a.closing_day ?? null, due_day: a.due_day ?? null, } @@ -92,6 +93,17 @@ function totalBalance() { +
+ % do CDI + +