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:
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user