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
+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
}