feat(#36): receitas recorrentes com confirmação de salário até dia 5

- Migration 009: coluna `type` em recurring_expenses + tabela recurring_late
- API Go: tipo income/expense em CRUD; endpoints POST /confirm e /late
- Dashboard: campo pending_income_recurrings na resposta
- Frontend: RecurringView com seções RECEITAS e DESPESAS separadas
- HomeView: widget de confirmação (dia 1-5) e badge SALÁRIO PENDENTE (pós dia 5)
- Testes unitários: 4 novos casos (income covered, late, confirm, reject expense)

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
2026-05-27 15:00:28 -03:00
co-authored by Claude Sonnet 4.6
parent 1931271b76
commit 79f4a62a1b
16 changed files with 725 additions and 77 deletions
+2
View File
@@ -105,6 +105,8 @@ func main() {
r.Get("/recurring/status", recurringHandler.MonthlyStatus)
r.Post("/recurring/{id}/ignore", recurringHandler.Ignore)
r.Delete("/recurring/{id}/ignore", recurringHandler.Unignore)
r.Post("/recurring/{id}/confirm", recurringHandler.ConfirmIncome)
r.Post("/recurring/{id}/late", recurringHandler.MarkLate)
r.Get("/accounts", accountHandler.List)
r.Post("/accounts", accountHandler.Create)
+46
View File
@@ -128,3 +128,49 @@ func (h *RecurringHandler) Unignore(w http.ResponseWriter, r *http.Request) {
}
w.WriteHeader(http.StatusNoContent)
}
func (h *RecurringHandler) ConfirmIncome(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(chi.URLParam(r, "id"))
if err != nil {
respondError(w, http.StatusBadRequest, "invalid id")
return
}
var body struct {
Month string `json:"month"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
respondError(w, http.StatusBadRequest, "invalid JSON")
return
}
if err := h.svc.ConfirmIncome(r.Context(), id, body.Month); errors.Is(err, repository.ErrNotFound) {
respondError(w, http.StatusNotFound, "not found")
return
} else if err != nil {
respondError(w, http.StatusBadRequest, err.Error())
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *RecurringHandler) MarkLate(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(chi.URLParam(r, "id"))
if err != nil {
respondError(w, http.StatusBadRequest, "invalid id")
return
}
var body struct {
Month string `json:"month"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
respondError(w, http.StatusBadRequest, "invalid JSON")
return
}
if err := h.svc.MarkLate(r.Context(), id, body.Month); errors.Is(err, repository.ErrNotFound) {
respondError(w, http.StatusNotFound, "not found")
return
} else if err != nil {
respondError(w, http.StatusBadRequest, err.Error())
return
}
w.WriteHeader(http.StatusNoContent)
}
@@ -0,0 +1,13 @@
-- Add type to recurring_expenses (income | expense, default expense — retrocompatível)
ALTER TABLE recurring_expenses
ADD COLUMN IF NOT EXISTS type VARCHAR(10) NOT NULL DEFAULT 'expense'
CHECK (type IN ('income', 'expense'));
-- Track income recurrings marked as "late" for a given month
CREATE TABLE IF NOT EXISTS recurring_late (
id SERIAL PRIMARY KEY,
recurring_id INTEGER NOT NULL REFERENCES recurring_expenses(id) ON DELETE CASCADE,
month VARCHAR(7) NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
UNIQUE(recurring_id, month)
);
+10 -9
View File
@@ -24,13 +24,14 @@ type RecentTransaction struct {
}
type DashboardData struct {
Month string `json:"month"`
TotalIncome float64 `json:"total_income"`
TotalExpenses float64 `json:"total_expenses"`
SavingsPct float64 `json:"savings_pct"`
TotalPatrimony float64 `json:"total_patrimony"`
ByCategory []CategoryTotal `json:"by_category"`
MonthlyEvolution []MonthEvolution `json:"monthly_evolution"`
RecentTransactions []RecentTransaction `json:"recent_transactions"`
PendingRecurring int `json:"pending_recurring"`
Month string `json:"month"`
TotalIncome float64 `json:"total_income"`
TotalExpenses float64 `json:"total_expenses"`
SavingsPct float64 `json:"savings_pct"`
TotalPatrimony float64 `json:"total_patrimony"`
ByCategory []CategoryTotal `json:"by_category"`
MonthlyEvolution []MonthEvolution `json:"monthly_evolution"`
RecentTransactions []RecentTransaction `json:"recent_transactions"`
PendingRecurring int `json:"pending_recurring"`
PendingIncomeRecurrings []PendingIncome `json:"pending_income_recurrings"`
}
+15 -3
View File
@@ -8,6 +8,7 @@ type RecurringExpense struct {
ExpectedAmount float64 `json:"expected_amount"`
DayOfMonth int `json:"day_of_month"`
CategoryID *int `json:"category_id"`
Type string `json:"type"` // "income" | "expense"
Active bool `json:"active"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
@@ -18,12 +19,23 @@ type RecurringInput struct {
ExpectedAmount float64 `json:"expected_amount"`
DayOfMonth int `json:"day_of_month"`
CategoryID *int `json:"category_id"`
Type string `json:"type"` // "income" | "expense"
}
// RecurringStatus reports whether a recurring expense is covered for a month.
// RecurringStatus reports whether a recurring item is covered/ignored/late for a month.
type RecurringStatus struct {
RecurringExpense
Covered bool `json:"covered"` // has a matching transaction
Ignored bool `json:"ignored"` // user explicitly ignored this month
Covered bool `json:"covered"` // has a matching transaction or was ignored
Ignored bool `json:"ignored"` // user explicitly ignored this month (expense)
Late bool `json:"late"` // user marked income as late this month
Reason string `json:"reason,omitempty"`
}
// PendingIncome is a lightweight summary of an income recurring pending confirmation.
type PendingIncome struct {
ID int `json:"id"`
Name string `json:"name"`
ExpectedAmount float64 `json:"expected_amount"`
DayOfMonth int `json:"day_of_month"`
Late bool `json:"late"`
}
+50 -14
View File
@@ -19,6 +19,9 @@ type RecurringRepository interface {
IsIgnored(ctx context.Context, id int, month string) (bool, string, error)
Ignore(ctx context.Context, id int, month, reason string) error
Unignore(ctx context.Context, id int, month string) error
IsLate(ctx context.Context, id int, month string) (bool, error)
MarkLate(ctx context.Context, id int, month string) error
UnmarkLate(ctx context.Context, id int, month string) error
}
type recurringRepo struct{ db *pgxpool.Pool }
@@ -29,7 +32,7 @@ func NewRecurringRepository(db *pgxpool.Pool) RecurringRepository {
func (r *recurringRepo) List(ctx context.Context) ([]model.RecurringExpense, error) {
rows, err := r.db.Query(ctx, `
SELECT id, name, expected_amount, day_of_month, category_id, active, created_at, updated_at
SELECT id, name, expected_amount, day_of_month, category_id, type, active, created_at, updated_at
FROM recurring_expenses ORDER BY name ASC`)
if err != nil {
return nil, err
@@ -38,7 +41,7 @@ func (r *recurringRepo) List(ctx context.Context) ([]model.RecurringExpense, err
var out []model.RecurringExpense
for rows.Next() {
var re model.RecurringExpense
if err := rows.Scan(&re.ID, &re.Name, &re.ExpectedAmount, &re.DayOfMonth, &re.CategoryID, &re.Active, &re.CreatedAt, &re.UpdatedAt); err != nil {
if err := rows.Scan(&re.ID, &re.Name, &re.ExpectedAmount, &re.DayOfMonth, &re.CategoryID, &re.Type, &re.Active, &re.CreatedAt, &re.UpdatedAt); err != nil {
return nil, err
}
out = append(out, re)
@@ -49,9 +52,9 @@ func (r *recurringRepo) List(ctx context.Context) ([]model.RecurringExpense, err
func (r *recurringRepo) GetByID(ctx context.Context, id int) (*model.RecurringExpense, error) {
var re model.RecurringExpense
err := r.db.QueryRow(ctx, `
SELECT id, name, expected_amount, day_of_month, category_id, active, created_at, updated_at
SELECT id, name, expected_amount, day_of_month, category_id, type, active, created_at, updated_at
FROM recurring_expenses WHERE id = $1`, id).
Scan(&re.ID, &re.Name, &re.ExpectedAmount, &re.DayOfMonth, &re.CategoryID, &re.Active, &re.CreatedAt, &re.UpdatedAt)
Scan(&re.ID, &re.Name, &re.ExpectedAmount, &re.DayOfMonth, &re.CategoryID, &re.Type, &re.Active, &re.CreatedAt, &re.UpdatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
@@ -59,25 +62,33 @@ func (r *recurringRepo) GetByID(ctx context.Context, id int) (*model.RecurringEx
}
func (r *recurringRepo) Create(ctx context.Context, in model.RecurringInput) (*model.RecurringExpense, error) {
t := in.Type
if t == "" {
t = "expense"
}
var re model.RecurringExpense
err := r.db.QueryRow(ctx, `
INSERT INTO recurring_expenses (name, expected_amount, day_of_month, category_id)
VALUES ($1, $2, $3, $4)
RETURNING id, name, expected_amount, day_of_month, category_id, active, created_at, updated_at`,
in.Name, in.ExpectedAmount, in.DayOfMonth, in.CategoryID).
Scan(&re.ID, &re.Name, &re.ExpectedAmount, &re.DayOfMonth, &re.CategoryID, &re.Active, &re.CreatedAt, &re.UpdatedAt)
INSERT INTO recurring_expenses (name, expected_amount, day_of_month, category_id, type)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, name, expected_amount, day_of_month, category_id, type, active, created_at, updated_at`,
in.Name, in.ExpectedAmount, in.DayOfMonth, in.CategoryID, t).
Scan(&re.ID, &re.Name, &re.ExpectedAmount, &re.DayOfMonth, &re.CategoryID, &re.Type, &re.Active, &re.CreatedAt, &re.UpdatedAt)
return &re, err
}
func (r *recurringRepo) Update(ctx context.Context, id int, in model.RecurringInput) (*model.RecurringExpense, error) {
t := in.Type
if t == "" {
t = "expense"
}
var re model.RecurringExpense
err := r.db.QueryRow(ctx, `
UPDATE recurring_expenses
SET name = $1, expected_amount = $2, day_of_month = $3, category_id = $4, updated_at = NOW()
WHERE id = $5
RETURNING id, name, expected_amount, day_of_month, category_id, active, created_at, updated_at`,
in.Name, in.ExpectedAmount, in.DayOfMonth, in.CategoryID, id).
Scan(&re.ID, &re.Name, &re.ExpectedAmount, &re.DayOfMonth, &re.CategoryID, &re.Active, &re.CreatedAt, &re.UpdatedAt)
SET name = $1, expected_amount = $2, day_of_month = $3, category_id = $4, type = $5, updated_at = NOW()
WHERE id = $6
RETURNING id, name, expected_amount, day_of_month, category_id, type, active, created_at, updated_at`,
in.Name, in.ExpectedAmount, in.DayOfMonth, in.CategoryID, t, id).
Scan(&re.ID, &re.Name, &re.ExpectedAmount, &re.DayOfMonth, &re.CategoryID, &re.Type, &re.Active, &re.CreatedAt, &re.UpdatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
@@ -119,3 +130,28 @@ func (r *recurringRepo) Unignore(ctx context.Context, id int, month string) erro
_, err := r.db.Exec(ctx, `DELETE FROM recurring_ignores WHERE recurring_id = $1 AND month = $2`, id, month)
return err
}
func (r *recurringRepo) IsLate(ctx context.Context, id int, month string) (bool, error) {
var count int
err := r.db.QueryRow(ctx,
`SELECT COUNT(*) FROM recurring_late WHERE recurring_id = $1 AND month = $2`,
id, month).Scan(&count)
if err != nil {
return false, err
}
return count > 0, nil
}
func (r *recurringRepo) MarkLate(ctx context.Context, id int, month string) error {
_, err := r.db.Exec(ctx, `
INSERT INTO recurring_late (recurring_id, month)
VALUES ($1, $2)
ON CONFLICT (recurring_id, month) DO NOTHING`,
id, month)
return err
}
func (r *recurringRepo) UnmarkLate(ctx context.Context, id int, month string) error {
_, err := r.db.Exec(ctx, `DELETE FROM recurring_late WHERE recurring_id = $1 AND month = $2`, id, month)
return err
}
@@ -16,8 +16,8 @@ type ManualTransactionRepository interface {
Create(ctx context.Context, t model.Transaction) (*model.Transaction, error)
Update(ctx context.Context, t model.Transaction) (*model.Transaction, error)
Delete(ctx context.Context, id int) error
// HasMatchingTransaction checks if a category has an expense in the given month.
HasMatchingTransaction(ctx context.Context, categoryID *int, month string, amount float64) (bool, error)
// HasMatchingTransaction checks if a category has a transaction of the given type in the given month.
HasMatchingTransaction(ctx context.Context, categoryID *int, month string, amount float64, txType string) (bool, error)
}
type manualTxRepo struct{ db *pgxpool.Pool }
@@ -103,20 +103,18 @@ func (r *manualTxRepo) Delete(ctx context.Context, id int) error {
return nil
}
func (r *manualTxRepo) HasMatchingTransaction(ctx context.Context, categoryID *int, month string, amount float64) (bool, error) {
var count int
var err error
if categoryID != nil {
err = r.db.QueryRow(ctx, `
SELECT COUNT(*) FROM transactions
WHERE category_id = $1
AND TO_CHAR(date, 'YYYY-MM') = $2
AND type = 'expense'
AND amount BETWEEN $3 * 0.9 AND $3 * 1.1`,
*categoryID, month, amount).Scan(&count)
} else {
func (r *manualTxRepo) HasMatchingTransaction(ctx context.Context, categoryID *int, month string, amount float64, txType string) (bool, error) {
if categoryID == nil {
// No category set — cannot auto-match, always report as uncovered
return false, nil
}
var count int
err := r.db.QueryRow(ctx, `
SELECT COUNT(*) FROM transactions
WHERE category_id = $1
AND TO_CHAR(date, 'YYYY-MM') = $2
AND type = $3
AND amount BETWEEN $4 * 0.9 AND $4 * 1.1`,
*categoryID, month, txType, amount).Scan(&count)
return count > 0, err
}
+29 -12
View File
@@ -58,11 +58,27 @@ func (s *DashboardService) Get(ctx context.Context, month string) (*model.Dashbo
return nil, err
}
pending := 0
for _, s := range statuses {
if !s.Covered {
pending++
var pendingIncome []model.PendingIncome
for _, st := range statuses {
if st.Type == "income" {
if !st.Covered {
pendingIncome = append(pendingIncome, model.PendingIncome{
ID: st.ID,
Name: st.Name,
ExpectedAmount: st.ExpectedAmount,
DayOfMonth: st.DayOfMonth,
Late: st.Late,
})
}
} else {
if !st.Covered {
pending++
}
}
}
if pendingIncome == nil {
pendingIncome = []model.PendingIncome{}
}
if byCategory == nil {
byCategory = []model.CategoryTotal{}
@@ -80,14 +96,15 @@ func (s *DashboardService) Get(ctx context.Context, month string) (*model.Dashbo
}
return &model.DashboardData{
Month: month,
TotalIncome: income,
TotalExpenses: expenses,
SavingsPct: savingsPct,
TotalPatrimony: patrimony,
ByCategory: byCategory,
MonthlyEvolution: evolution,
RecentTransactions: recent,
PendingRecurring: pending,
Month: month,
TotalIncome: income,
TotalExpenses: expenses,
SavingsPct: savingsPct,
TotalPatrimony: patrimony,
ByCategory: byCategory,
MonthlyEvolution: evolution,
RecentTransactions: recent,
PendingRecurring: pending,
PendingIncomeRecurrings: pendingIncome,
}, nil
}
+70 -13
View File
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"strings"
"time"
"financeiro-carvalho/internal/model"
"financeiro-carvalho/internal/repository"
@@ -13,6 +14,7 @@ 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")
ErrRecurringNotIncome = errors.New("recurring item is not of type income")
)
type RecurringService struct {
@@ -52,7 +54,7 @@ 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).
// MonthlyStatus checks which active recurring items are covered/ignored/late 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 {
@@ -64,27 +66,82 @@ func (s *RecurringService) MonthlyStatus(ctx context.Context, month string) ([]m
if !re.Active {
continue
}
ignored, reason, err := s.repo.IsIgnored(ctx, re.ID, month)
if err != nil {
return nil, err
txType := re.Type
if txType == "" {
txType = "expense"
}
covered := false
if !ignored {
covered, err = s.txRepo.HasMatchingTransaction(ctx, re.CategoryID, month, re.ExpectedAmount)
if txType == "expense" {
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, "expense")
if err != nil {
return nil, err
}
}
result = append(result, model.RecurringStatus{
RecurringExpense: re,
Covered: covered || ignored,
Ignored: ignored,
Reason: reason,
})
} else {
// income type
late, err := s.repo.IsLate(ctx, re.ID, month)
if err != nil {
return nil, err
}
covered, err := s.txRepo.HasMatchingTransaction(ctx, re.CategoryID, month, re.ExpectedAmount, "income")
if err != nil {
return nil, err
}
result = append(result, model.RecurringStatus{
RecurringExpense: re,
Covered: covered,
Late: late,
})
}
result = append(result, model.RecurringStatus{
RecurringExpense: re,
Covered: covered || ignored,
Ignored: ignored,
Reason: reason,
})
}
return result, nil
}
// ConfirmIncome creates an income transaction confirming receipt of this recurring income.
func (s *RecurringService) ConfirmIncome(ctx context.Context, id int, month string) error {
re, err := s.repo.GetByID(ctx, id)
if err != nil {
return err
}
if re.Type != "income" {
return ErrRecurringNotIncome
}
today := time.Now().Format("2006-01-02")
tx := model.Transaction{
Date: today,
Amount: re.ExpectedAmount,
Description: re.Name,
Type: "income",
CategoryID: re.CategoryID,
}
_, err = s.txRepo.Create(ctx, tx)
return err
}
func (s *RecurringService) MarkLate(ctx context.Context, id int, month string) error {
re, err := s.repo.GetByID(ctx, id)
if err != nil {
return err
}
if re.Type != "income" {
return ErrRecurringNotIncome
}
return s.repo.MarkLate(ctx, id, month)
}
func (s *RecurringService) Ignore(ctx context.Context, id int, month, reason string) error {
return s.repo.Ignore(ctx, id, month, reason)
}
+72 -6
View File
@@ -14,10 +14,11 @@ import (
type mockRecurringRepo struct {
items []model.RecurringExpense
ignores map[string]string // "id:month" → reason
lates map[string]bool // "id:month" → true
}
func newMockRecurring(items []model.RecurringExpense) *mockRecurringRepo {
return &mockRecurringRepo{items: items, ignores: map[string]string{}}
return &mockRecurringRepo{items: items, ignores: map[string]string{}, lates: map[string]bool{}}
}
func (m *mockRecurringRepo) List(_ context.Context) ([]model.RecurringExpense, error) {
@@ -33,7 +34,7 @@ func (m *mockRecurringRepo) GetByID(_ context.Context, id int) (*model.Recurring
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}
r := model.RecurringExpense{ID: len(m.items) + 1, Name: in.Name, ExpectedAmount: in.ExpectedAmount, DayOfMonth: in.DayOfMonth, Type: in.Type, Active: true}
m.items = append(m.items, r)
return &r, nil
}
@@ -62,6 +63,17 @@ func (m *mockRecurringRepo) Unignore(_ context.Context, id int, month string) er
delete(m.ignores, string(rune(id))+":"+month)
return nil
}
func (m *mockRecurringRepo) IsLate(_ context.Context, id int, month string) (bool, error) {
return m.lates[string(rune(id))+":"+month], nil
}
func (m *mockRecurringRepo) MarkLate(_ context.Context, id int, month string) error {
m.lates[string(rune(id))+":"+month] = true
return nil
}
func (m *mockRecurringRepo) UnmarkLate(_ context.Context, id int, month string) error {
delete(m.lates, string(rune(id))+":"+month)
return nil
}
// ── mock tx repo ─────────────────────────────────────────────────────────────
@@ -78,7 +90,7 @@ func (m *mockTxRepo) Update(_ context.Context, t model.Transaction) (*model.Tran
return &t, nil
}
func (m *mockTxRepo) Delete(_ context.Context, _ int) error { return nil }
func (m *mockTxRepo) HasMatchingTransaction(_ context.Context, _ *int, _ string, _ float64) (bool, error) {
func (m *mockTxRepo) HasMatchingTransaction(_ context.Context, _ *int, _ string, _ float64, _ string) (bool, error) {
return m.matchResult, nil
}
@@ -87,7 +99,7 @@ func (m *mockTxRepo) HasMatchingTransaction(_ context.Context, _ *int, _ string,
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}
re := model.RecurringExpense{ID: 1, Name: "Plano Saúde", ExpectedAmount: 300, DayOfMonth: 5, CategoryID: &catID, Type: "expense", Active: true}
svc := service.NewRecurringService(newMockRecurring([]model.RecurringExpense{re}), &mockTxRepo{matchResult: true})
statuses, err := svc.MonthlyStatus(context.Background(), "2024-03")
@@ -103,7 +115,7 @@ func TestMonthlyStatus_Covered(t *testing.T) {
}
func TestMonthlyStatus_Uncovered(t *testing.T) {
re := model.RecurringExpense{ID: 2, Name: "Netflix", ExpectedAmount: 55, DayOfMonth: 10, CategoryID: &catID, Active: true}
re := model.RecurringExpense{ID: 2, Name: "Netflix", ExpectedAmount: 55, DayOfMonth: 10, CategoryID: &catID, Type: "expense", Active: true}
svc := service.NewRecurringService(newMockRecurring([]model.RecurringExpense{re}), &mockTxRepo{matchResult: false})
statuses, err := svc.MonthlyStatus(context.Background(), "2024-03")
@@ -116,7 +128,7 @@ func TestMonthlyStatus_Uncovered(t *testing.T) {
}
func TestMonthlyStatus_Ignored_CountsAsCovered(t *testing.T) {
re := model.RecurringExpense{ID: 3, Name: "Seguro", ExpectedAmount: 200, DayOfMonth: 1, CategoryID: &catID, Active: true}
re := model.RecurringExpense{ID: 3, Name: "Seguro", ExpectedAmount: 200, DayOfMonth: 1, CategoryID: &catID, Type: "expense", Active: true}
repo := newMockRecurring([]model.RecurringExpense{re})
_ = repo.Ignore(context.Background(), 3, "2024-03", "viagem")
svc := service.NewRecurringService(repo, &mockTxRepo{matchResult: false})
@@ -133,6 +145,60 @@ func TestMonthlyStatus_Ignored_CountsAsCovered(t *testing.T) {
}
}
func TestMonthlyStatus_IncomeConfirmed(t *testing.T) {
re := model.RecurringExpense{ID: 4, Name: "Salário", ExpectedAmount: 3200, DayOfMonth: 5, CategoryID: &catID, Type: "income", 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("income with matching transaction should be covered")
}
}
func TestMonthlyStatus_IncomeLate(t *testing.T) {
re := model.RecurringExpense{ID: 5, Name: "Salário", ExpectedAmount: 3200, DayOfMonth: 5, CategoryID: &catID, Type: "income", Active: true}
repo := newMockRecurring([]model.RecurringExpense{re})
_ = repo.MarkLate(context.Background(), 5, "2024-03")
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("late income should NOT be covered")
}
if !statuses[0].Late {
t.Error("expected Late=true")
}
}
func TestConfirmIncome_CreatesTransaction(t *testing.T) {
re := model.RecurringExpense{ID: 6, Name: "Salário", ExpectedAmount: 3200, DayOfMonth: 5, CategoryID: &catID, Type: "income", Active: true}
svc := service.NewRecurringService(newMockRecurring([]model.RecurringExpense{re}), &mockTxRepo{})
err := svc.ConfirmIncome(context.Background(), 6, "2024-03")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestConfirmIncome_RejectsExpenseType(t *testing.T) {
re := model.RecurringExpense{ID: 7, Name: "Netflix", ExpectedAmount: 55, DayOfMonth: 10, CategoryID: &catID, Type: "expense", Active: true}
svc := service.NewRecurringService(newMockRecurring([]model.RecurringExpense{re}), &mockTxRepo{})
err := svc.ConfirmIncome(context.Background(), 7, "2024-03")
if err != service.ErrRecurringNotIncome {
t.Fatalf("expected ErrRecurringNotIncome, got %v", err)
}
}
func TestCreateRecurring_Validation(t *testing.T) {
svc := service.NewRecurringService(newMockRecurring(nil), &mockTxRepo{})