feat(#21): contas bancárias e patrimônio consolidado

CRUD de contas (corrente/poupança/investimento/cartão) com saldo calculado
automaticamente. account_id nullable em transactions. Widget patrimônio no
dashboard. Tela /contas. Seletor de conta nas transações manuais.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
2026-05-26 21:34:56 -03:00
co-authored by Claude Sonnet 4.6
parent fbcb1d76aa
commit 9a964423fd
21 changed files with 667 additions and 24 deletions
+70
View File
@@ -0,0 +1,70 @@
package service
import (
"context"
"errors"
"financeiro-carvalho/internal/model"
)
var (
ErrAccountEmptyName = errors.New("account name cannot be empty")
ErrAccountInvalidType = errors.New("account type must be checking, savings, investment, or credit")
)
var validAccountTypes = map[string]bool{
"checking": true, "savings": true, "investment": true, "credit": true,
}
type AccountRepo 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)
}
type AccountService struct {
repo AccountRepo
}
func NewAccountService(repo AccountRepo) *AccountService {
return &AccountService{repo: repo}
}
func (s *AccountService) validate(in model.AccountInput) error {
if in.Name == "" {
return ErrAccountEmptyName
}
if !validAccountTypes[in.Type] {
return ErrAccountInvalidType
}
return nil
}
func (s *AccountService) List(ctx context.Context) ([]model.Account, error) {
return s.repo.List(ctx)
}
func (s *AccountService) Create(ctx context.Context, in model.AccountInput) (*model.Account, error) {
if err := s.validate(in); err != nil {
return nil, err
}
return s.repo.Create(ctx, in)
}
func (s *AccountService) Update(ctx context.Context, id int, in model.AccountInput) (*model.Account, error) {
if err := s.validate(in); err != nil {
return nil, err
}
return s.repo.Update(ctx, id, in)
}
func (s *AccountService) Delete(ctx context.Context, id int) error {
return s.repo.Delete(ctx, id)
}
func (s *AccountService) TotalPatrimony(ctx context.Context) (float64, error) {
return s.repo.TotalPatrimony(ctx)
}
+68
View File
@@ -0,0 +1,68 @@
package service_test
import (
"context"
"testing"
"financeiro-carvalho/internal/model"
"financeiro-carvalho/internal/service"
)
type mockAccountRepo struct {
items []model.Account
}
func (m *mockAccountRepo) List(_ context.Context) ([]model.Account, error) { return m.items, nil }
func (m *mockAccountRepo) GetByID(_ context.Context, id int) (*model.Account, error) {
for _, a := range m.items {
if a.ID == id {
cp := a
return &cp, nil
}
}
return nil, nil
}
func (m *mockAccountRepo) Create(_ context.Context, in model.AccountInput) (*model.Account, error) {
a := model.Account{ID: len(m.items) + 1, Name: in.Name, Type: in.Type, InitialBalance: in.InitialBalance, Balance: in.InitialBalance}
m.items = append(m.items, a)
return &a, nil
}
func (m *mockAccountRepo) Update(_ context.Context, id int, in model.AccountInput) (*model.Account, error) {
for i, a := range m.items {
if a.ID == id {
m.items[i].Name = in.Name
cp := m.items[i]
return &cp, nil
}
}
return nil, nil
}
func (m *mockAccountRepo) Delete(_ context.Context, _ int) error { return nil }
func (m *mockAccountRepo) TotalPatrimony(_ context.Context) (float64, error) { return 0, nil }
func TestCreateAccount_EmptyName(t *testing.T) {
svc := service.NewAccountService(&mockAccountRepo{})
_, err := svc.Create(context.Background(), model.AccountInput{Name: "", Type: "checking", InitialBalance: 0})
if err != service.ErrAccountEmptyName {
t.Fatalf("expected ErrAccountEmptyName, got %v", err)
}
}
func TestCreateAccount_InvalidType(t *testing.T) {
svc := service.NewAccountService(&mockAccountRepo{})
_, err := svc.Create(context.Background(), model.AccountInput{Name: "Nubank", Type: "bitcoin"})
if err != service.ErrAccountInvalidType {
t.Fatalf("expected ErrAccountInvalidType, got %v", err)
}
}
func TestCreateAccount_OK(t *testing.T) {
svc := service.NewAccountService(&mockAccountRepo{})
a, err := svc.Create(context.Background(), model.AccountInput{Name: "Nubank", Type: "checking", InitialBalance: 1000})
if err != nil {
t.Fatal(err)
}
if a.Name != "Nubank" || a.Balance != 1000 {
t.Errorf("unexpected account: %+v", a)
}
}
+16 -5
View File
@@ -13,13 +13,18 @@ type DashboardRepo interface {
RecentTransactions(ctx context.Context, month string) ([]model.RecentTransaction, error)
}
type DashboardService struct {
repo DashboardRepo
recurrSvc *RecurringService
type PatrimonySource interface {
TotalPatrimony(ctx context.Context) (float64, error)
}
func NewDashboardService(repo DashboardRepo, recurrSvc *RecurringService) *DashboardService {
return &DashboardService{repo: repo, recurrSvc: recurrSvc}
type DashboardService struct {
repo DashboardRepo
recurrSvc *RecurringService
patrimony PatrimonySource
}
func NewDashboardService(repo DashboardRepo, recurrSvc *RecurringService, patrimony PatrimonySource) *DashboardService {
return &DashboardService{repo: repo, recurrSvc: recurrSvc, patrimony: patrimony}
}
func (s *DashboardService) Get(ctx context.Context, month string) (*model.DashboardData, error) {
@@ -69,11 +74,17 @@ func (s *DashboardService) Get(ctx context.Context, month string) (*model.Dashbo
recent = []model.RecentTransaction{}
}
patrimony, err := s.patrimony.TotalPatrimony(ctx)
if err != nil {
return nil, err
}
return &model.DashboardData{
Month: month,
TotalIncome: income,
TotalExpenses: expenses,
SavingsPct: savingsPct,
TotalPatrimony: patrimony,
ByCategory: byCategory,
MonthlyEvolution: evolution,
RecentTransactions: recent,
+5 -1
View File
@@ -26,9 +26,13 @@ func (m *mockDashboardRepo) RecentTransactions(_ context.Context, _ string) ([]m
return nil, nil
}
type mockPatrimony struct{}
func (m *mockPatrimony) TotalPatrimony(_ context.Context) (float64, error) { return 0, nil }
func newDashboardSvc(income, expenses float64) *service.DashboardService {
recurrSvc := service.NewRecurringService(newMockRecurring(nil), &mockTxRepo{})
return service.NewDashboardService(&mockDashboardRepo{income: income, expenses: expenses}, recurrSvc)
return service.NewDashboardService(&mockDashboardRepo{income: income, expenses: expenses}, recurrSvc, &mockPatrimony{})
}
func TestDashboard_SavingsPct_40(t *testing.T) {