feat(#19): registro manual de transações e recorrências fixas

Adiciona CRUD manual de transações (income/expense) com filtro mensal, CRUD de
recorrências fixas com verificação mensal de cobertura (±10% por categoria),
endpoint de ignore/unignore e telas Vue para Transações e Configurações.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
2026-05-26 20:26:48 -03:00
co-authored by Claude Sonnet 4.6
parent 83dff2de78
commit 12e8500827
17 changed files with 1351 additions and 8 deletions
+107
View File
@@ -0,0 +1,107 @@
package service
import (
"context"
"errors"
"strings"
"financeiro-carvalho/internal/model"
"financeiro-carvalho/internal/repository"
)
var (
ErrRecurringEmptyName = errors.New("name is required")
ErrRecurringInvalidAmount = errors.New("expected_amount must be greater than zero")
ErrRecurringInvalidDayOfMonth = errors.New("day_of_month must be between 1 and 31")
)
type RecurringService struct {
repo repository.RecurringRepository
txRepo repository.ManualTransactionRepository
}
func NewRecurringService(repo repository.RecurringRepository, txRepo repository.ManualTransactionRepository) *RecurringService {
return &RecurringService{repo: repo, txRepo: txRepo}
}
func (s *RecurringService) List(ctx context.Context) ([]model.RecurringExpense, error) {
items, err := s.repo.List(ctx)
if items == nil {
return []model.RecurringExpense{}, err
}
return items, err
}
func (s *RecurringService) Create(ctx context.Context, in model.RecurringInput) (*model.RecurringExpense, error) {
if err := validateRecurringInput(in); err != nil {
return nil, err
}
in.Name = strings.TrimSpace(in.Name)
return s.repo.Create(ctx, in)
}
func (s *RecurringService) Update(ctx context.Context, id int, in model.RecurringInput) (*model.RecurringExpense, error) {
if err := validateRecurringInput(in); err != nil {
return nil, err
}
in.Name = strings.TrimSpace(in.Name)
return s.repo.Update(ctx, id, in)
}
func (s *RecurringService) Delete(ctx context.Context, id int) error {
return s.repo.Delete(ctx, id)
}
// MonthlyStatus checks which active recurring expenses are covered/ignored for the given month (YYYY-MM).
func (s *RecurringService) MonthlyStatus(ctx context.Context, month string) ([]model.RecurringStatus, error) {
items, err := s.repo.List(ctx)
if err != nil {
return nil, err
}
var result []model.RecurringStatus
for _, re := range items {
if !re.Active {
continue
}
ignored, reason, err := s.repo.IsIgnored(ctx, re.ID, month)
if err != nil {
return nil, err
}
covered := false
if !ignored {
covered, err = s.txRepo.HasMatchingTransaction(ctx, re.CategoryID, month, re.ExpectedAmount)
if err != nil {
return nil, err
}
}
result = append(result, model.RecurringStatus{
RecurringExpense: re,
Covered: covered || ignored,
Ignored: ignored,
Reason: reason,
})
}
return result, nil
}
func (s *RecurringService) Ignore(ctx context.Context, id int, month, reason string) error {
return s.repo.Ignore(ctx, id, month, reason)
}
func (s *RecurringService) Unignore(ctx context.Context, id int, month string) error {
return s.repo.Unignore(ctx, id, month)
}
func validateRecurringInput(in model.RecurringInput) error {
if strings.TrimSpace(in.Name) == "" {
return ErrRecurringEmptyName
}
if in.ExpectedAmount <= 0 {
return ErrRecurringInvalidAmount
}
if in.DayOfMonth < 1 || in.DayOfMonth > 31 {
return ErrRecurringInvalidDayOfMonth
}
return nil
}
+153
View File
@@ -0,0 +1,153 @@
package service_test
import (
"context"
"testing"
"financeiro-carvalho/internal/model"
"financeiro-carvalho/internal/repository"
"financeiro-carvalho/internal/service"
)
// ── mock recurring repo ──────────────────────────────────────────────────────
type mockRecurringRepo struct {
items []model.RecurringExpense
ignores map[string]string // "id:month" → reason
}
func newMockRecurring(items []model.RecurringExpense) *mockRecurringRepo {
return &mockRecurringRepo{items: items, ignores: map[string]string{}}
}
func (m *mockRecurringRepo) List(_ context.Context) ([]model.RecurringExpense, error) {
return m.items, nil
}
func (m *mockRecurringRepo) GetByID(_ context.Context, id int) (*model.RecurringExpense, error) {
for _, r := range m.items {
if r.ID == id {
cp := r
return &cp, nil
}
}
return nil, repository.ErrNotFound
}
func (m *mockRecurringRepo) Create(_ context.Context, in model.RecurringInput) (*model.RecurringExpense, error) {
r := model.RecurringExpense{ID: len(m.items) + 1, Name: in.Name, ExpectedAmount: in.ExpectedAmount, DayOfMonth: in.DayOfMonth, Active: true}
m.items = append(m.items, r)
return &r, nil
}
func (m *mockRecurringRepo) Update(_ context.Context, id int, in model.RecurringInput) (*model.RecurringExpense, error) {
for i, r := range m.items {
if r.ID == id {
m.items[i].Name = in.Name
m.items[i].ExpectedAmount = in.ExpectedAmount
cp := m.items[i]
return &cp, nil
}
}
return nil, repository.ErrNotFound
}
func (m *mockRecurringRepo) Delete(_ context.Context, id int) error { return nil }
func (m *mockRecurringRepo) IsIgnored(_ context.Context, id int, month string) (bool, string, error) {
key := string(rune(id)) + ":" + month
r, ok := m.ignores[key]
return ok, r, nil
}
func (m *mockRecurringRepo) Ignore(_ context.Context, id int, month, reason string) error {
m.ignores[string(rune(id))+":"+month] = reason
return nil
}
func (m *mockRecurringRepo) Unignore(_ context.Context, id int, month string) error {
delete(m.ignores, string(rune(id))+":"+month)
return nil
}
// ── mock tx repo ─────────────────────────────────────────────────────────────
type mockTxRepo struct {
matchResult bool
}
func (m *mockTxRepo) List(_ context.Context, _ string) ([]model.Transaction, error) { return nil, nil }
func (m *mockTxRepo) GetByID(_ context.Context, _ int) (*model.Transaction, error) { return nil, nil }
func (m *mockTxRepo) Create(_ context.Context, t model.Transaction) (*model.Transaction, error) {
return &t, nil
}
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) HasMatchingTransaction(_ context.Context, _ *int, _ string, _ float64) (bool, error) {
return m.matchResult, nil
}
// ── tests ─────────────────────────────────────────────────────────────────────
var catID = 1
func TestMonthlyStatus_Covered(t *testing.T) {
re := model.RecurringExpense{ID: 1, Name: "Plano Saúde", ExpectedAmount: 300, DayOfMonth: 5, CategoryID: &catID, Active: true}
svc := service.NewRecurringService(newMockRecurring([]model.RecurringExpense{re}), &mockTxRepo{matchResult: true})
statuses, err := svc.MonthlyStatus(context.Background(), "2024-03")
if err != nil {
t.Fatal(err)
}
if len(statuses) != 1 {
t.Fatalf("expected 1, got %d", len(statuses))
}
if !statuses[0].Covered {
t.Error("expected Covered=true")
}
}
func TestMonthlyStatus_Uncovered(t *testing.T) {
re := model.RecurringExpense{ID: 2, Name: "Netflix", ExpectedAmount: 55, DayOfMonth: 10, CategoryID: &catID, Active: true}
svc := service.NewRecurringService(newMockRecurring([]model.RecurringExpense{re}), &mockTxRepo{matchResult: false})
statuses, err := svc.MonthlyStatus(context.Background(), "2024-03")
if err != nil {
t.Fatal(err)
}
if statuses[0].Covered {
t.Error("expected Covered=false")
}
}
func TestMonthlyStatus_Ignored_CountsAsCovered(t *testing.T) {
re := model.RecurringExpense{ID: 3, Name: "Seguro", ExpectedAmount: 200, DayOfMonth: 1, CategoryID: &catID, Active: true}
repo := newMockRecurring([]model.RecurringExpense{re})
_ = repo.Ignore(context.Background(), 3, "2024-03", "viagem")
svc := service.NewRecurringService(repo, &mockTxRepo{matchResult: false})
statuses, err := svc.MonthlyStatus(context.Background(), "2024-03")
if err != nil {
t.Fatal(err)
}
if !statuses[0].Covered {
t.Error("ignored should count as covered")
}
if !statuses[0].Ignored {
t.Error("expected Ignored=true")
}
}
func TestCreateRecurring_Validation(t *testing.T) {
svc := service.NewRecurringService(newMockRecurring(nil), &mockTxRepo{})
_, err := svc.Create(context.Background(), model.RecurringInput{Name: "", ExpectedAmount: 100, DayOfMonth: 5})
if err != service.ErrRecurringEmptyName {
t.Fatalf("expected ErrRecurringEmptyName, got %v", err)
}
_, err = svc.Create(context.Background(), model.RecurringInput{Name: "X", ExpectedAmount: -1, DayOfMonth: 5})
if err != service.ErrRecurringInvalidAmount {
t.Fatalf("expected ErrRecurringInvalidAmount, got %v", err)
}
_, err = svc.Create(context.Background(), model.RecurringInput{Name: "X", ExpectedAmount: 100, DayOfMonth: 0})
if err != service.ErrRecurringInvalidDayOfMonth {
t.Fatalf("expected ErrRecurringInvalidDayOfMonth, got %v", err)
}
}
+74
View File
@@ -0,0 +1,74 @@
package service
import (
"context"
"errors"
"strings"
"financeiro-carvalho/internal/model"
"financeiro-carvalho/internal/repository"
)
var (
ErrInvalidTransactionType = errors.New("type must be 'income' or 'expense'")
ErrCannotEditImported = errors.New("imported transactions cannot be edited or deleted")
ErrTransactionEmptyDesc = errors.New("description is required")
ErrTransactionInvalidAmount = errors.New("amount must be greater than zero")
)
type TransactionService struct {
repo repository.ManualTransactionRepository
}
func NewTransactionService(repo repository.ManualTransactionRepository) *TransactionService {
return &TransactionService{repo: repo}
}
func (s *TransactionService) List(ctx context.Context, month string) ([]model.Transaction, error) {
txs, err := s.repo.List(ctx, month)
if txs == nil {
return []model.Transaction{}, err
}
return txs, err
}
func (s *TransactionService) Create(ctx context.Context, t model.Transaction) (*model.Transaction, error) {
if err := validateTransaction(t); err != nil {
return nil, err
}
t.Description = strings.TrimSpace(t.Description)
return s.repo.Create(ctx, t)
}
func (s *TransactionService) Update(ctx context.Context, t model.Transaction) (*model.Transaction, error) {
if err := validateTransaction(t); err != nil {
return nil, err
}
t.Description = strings.TrimSpace(t.Description)
out, err := s.repo.Update(ctx, t)
if errors.Is(err, repository.ErrNotFound) {
return nil, ErrCannotEditImported
}
return out, err
}
func (s *TransactionService) Delete(ctx context.Context, id int) error {
err := s.repo.Delete(ctx, id)
if errors.Is(err, repository.ErrNotFound) {
return ErrCannotEditImported
}
return err
}
func validateTransaction(t model.Transaction) error {
if strings.TrimSpace(t.Description) == "" {
return ErrTransactionEmptyDesc
}
if t.Amount <= 0 {
return ErrTransactionInvalidAmount
}
if t.Type != "income" && t.Type != "expense" {
return ErrInvalidTransactionType
}
return nil
}