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