Files
carvalho-finances/apps/api/internal/repository/recurring.go
T
Mlcavalho1andClaude Sonnet 4.6 79f4a62a1b 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]>
2026-05-27 15:00:28 -03:00

158 lines
5.4 KiB
Go

package repository
import (
"context"
"errors"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"financeiro-carvalho/internal/model"
)
type RecurringRepository interface {
List(ctx context.Context) ([]model.RecurringExpense, error)
GetByID(ctx context.Context, id int) (*model.RecurringExpense, error)
Create(ctx context.Context, in model.RecurringInput) (*model.RecurringExpense, error)
Update(ctx context.Context, id int, in model.RecurringInput) (*model.RecurringExpense, error)
Delete(ctx context.Context, id int) error
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 }
func NewRecurringRepository(db *pgxpool.Pool) RecurringRepository {
return &recurringRepo{db: db}
}
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, type, active, created_at, updated_at
FROM recurring_expenses ORDER BY name ASC`)
if err != nil {
return nil, err
}
defer rows.Close()
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.Type, &re.Active, &re.CreatedAt, &re.UpdatedAt); err != nil {
return nil, err
}
out = append(out, re)
}
return out, rows.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, 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.Type, &re.Active, &re.CreatedAt, &re.UpdatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return &re, err
}
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, 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, 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
}
return &re, err
}
func (r *recurringRepo) Delete(ctx context.Context, id int) error {
tag, err := r.db.Exec(ctx, `DELETE FROM recurring_expenses WHERE id = $1`, id)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
func (r *recurringRepo) IsIgnored(ctx context.Context, id int, month string) (bool, string, error) {
var reason string
err := r.db.QueryRow(ctx,
`SELECT COALESCE(reason, '') FROM recurring_ignores WHERE recurring_id = $1 AND month = $2`,
id, month).Scan(&reason)
if errors.Is(err, pgx.ErrNoRows) {
return false, "", nil
}
return err == nil, reason, err
}
func (r *recurringRepo) Ignore(ctx context.Context, id int, month, reason string) error {
_, err := r.db.Exec(ctx, `
INSERT INTO recurring_ignores (recurring_id, month, reason)
VALUES ($1, $2, $3)
ON CONFLICT (recurring_id, month) DO UPDATE SET reason = $3`,
id, month, reason)
return err
}
func (r *recurringRepo) Unignore(ctx context.Context, id int, month string) error {
_, 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
}