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{})
+1 -1
View File
@@ -49,7 +49,7 @@ const navItems = [
{ to: '/importar', label: 'IMPORT' },
{ to: '/contas', label: 'CONTAS' },
{ to: '/personagem', label: 'PERS.' },
{ to: '/configuracoes', label: 'CFG' },
{ to: '/recorrencias', label: 'REC.' },
]
</script>
+6 -2
View File
@@ -41,10 +41,14 @@ const router = createRouter({
name: 'character',
component: () => import('../views/CharacterView.vue'),
},
{
path: '/recorrencias',
name: 'recurring',
component: () => import('../views/RecurringView.vue'),
},
{
path: '/configuracoes',
name: 'settings',
component: () => import('../views/SettingsView.vue'),
redirect: '/recorrencias',
},
],
})
+9
View File
@@ -25,6 +25,14 @@ export interface RecentTransaction {
type: 'income' | 'expense'
}
export interface PendingIncome {
id: number
name: string
expected_amount: number
day_of_month: number
late: boolean
}
export interface DashboardData {
month: string
total_income: number
@@ -35,6 +43,7 @@ export interface DashboardData {
monthly_evolution: MonthEvolution[]
recent_transactions: RecentTransaction[]
pending_recurring: number
pending_income_recurrings: PendingIncome[]
}
export const useDashboardStore = defineStore('dashboard', () => {
+28 -2
View File
@@ -1,7 +1,6 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { api } from '@/services/api'
import type { Category } from './categories'
export interface RecurringExpense {
id: number
@@ -9,6 +8,7 @@ export interface RecurringExpense {
expected_amount: number
day_of_month: number
category_id: number | null
type: 'income' | 'expense'
active: boolean
created_at: string
updated_at: string
@@ -17,6 +17,7 @@ export interface RecurringExpense {
export interface RecurringStatus extends RecurringExpense {
covered: boolean
ignored: boolean
late: boolean
reason?: string
}
@@ -74,8 +75,33 @@ export const useRecurringStore = defineStore('recurring', () => {
await fetchMonthlyStatus(month)
}
async function confirmIncome(id: number, month: string) {
await api.post(`/recurring/${id}/confirm`, { month })
await fetchMonthlyStatus(month)
}
async function markLate(id: number, month: string) {
await api.post(`/recurring/${id}/late`, { month })
await fetchMonthlyStatus(month)
}
const pendingCount = (month: string) =>
monthlyStatus.value.filter((s) => !s.covered).length
return { items, monthlyStatus, loading, error, fetchAll, fetchMonthlyStatus, create, update, remove, ignore, unignore, pendingCount }
return {
items,
monthlyStatus,
loading,
error,
fetchAll,
fetchMonthlyStatus,
create,
update,
remove,
ignore,
unignore,
confirmIncome,
markLate,
pendingCount,
}
})
+69 -1
View File
@@ -2,6 +2,7 @@
import { ref, computed, onMounted } from 'vue'
import { useDashboardStore } from '@/stores/dashboard'
import { useGameStore } from '@/stores/game'
import { useRecurringStore } from '@/stores/recurring'
import NeonPanel from '@/components/NeonPanel.vue'
import XPBar from '@/components/XPBar.vue'
import CharacterSprite from '@/components/CharacterSprite.vue'
@@ -9,6 +10,7 @@ import MonthSwitcher from '@/components/MonthSwitcher.vue'
const dash = useDashboardStore()
const game = useGameStore()
const recurring = useRecurringStore()
const currentMonth = ref(new Date().toISOString().slice(0, 7))
onMounted(() => dash.fetch(currentMonth.value))
@@ -51,6 +53,22 @@ const xpPct = computed(() => {
})
const savingsPct = computed(() => dash.data?.savings_pct ?? 0)
const savingsOk = computed(() => savingsPct.value >= 40)
const today = new Date()
const isCurrentMonth = computed(() => currentMonth.value === today.toISOString().slice(0, 7))
const dayOfMonth = today.getDate()
const pendingIncome = computed(() => dash.data?.pending_income_recurrings ?? [])
const showIncomeWidget = computed(() => isCurrentMonth.value && pendingIncome.value.length > 0)
const showIncomeConfirmButtons = computed(() => dayOfMonth <= 5)
async function confirmIncome(id: number) {
await recurring.confirmIncome(id, currentMonth.value)
await dash.fetch(currentMonth.value)
}
async function markLate(id: number) {
await recurring.markLate(id, currentMonth.value)
await dash.fetch(currentMonth.value)
}
</script>
<template>
@@ -64,7 +82,7 @@ const savingsOk = computed(() => savingsPct.value >= 40)
<div v-if="dash.loading" class="dash-loading fc-pixel">CARREGANDO...</div>
<template v-else-if="dash.data">
<!-- Alert banner -->
<!-- Alert banner despesas recorrentes ausentes -->
<div v-if="dash.data.pending_recurring > 0" class="dash-alert">
<span class="fc-blink" style="color:var(--fc-red)"></span>
<span class="fc-pixel" style="font-size:8px">
@@ -72,6 +90,37 @@ const savingsOk = computed(() => savingsPct.value >= 40)
</span>
</div>
<!-- Income confirmation widget (day 15 of current month) -->
<template v-if="showIncomeWidget">
<NeonPanel v-if="showIncomeConfirmButtons" title="CONFIRMAR RECEITAS" :variant="'income'" class="dash-income-panel">
<div class="income-list">
<div v-for="item in pendingIncome" :key="item.id" class="income-item">
<div class="income-item__info">
<span class="fc-body income-item__name">{{ item.name }}</span>
<span class="fc-mono income-item__amount" style="color:var(--fc-green)">
{{ fmt(item.expected_amount) }}
</span>
</div>
<div v-if="item.late" class="income-item__actions">
<span class="fc-chip income-chip--late fc-pixel">ATRASOU</span>
</div>
<div v-else class="income-item__actions">
<button class="fc-btn fc-btn--sm fc-btn--primary" @click="confirmIncome(item.id)">CONFIRMAR</button>
<button class="fc-btn fc-btn--sm fc-btn--ghost" @click="markLate(item.id)">ATRASOU</button>
</div>
</div>
</div>
</NeonPanel>
<!-- After day 5: salary pending badge -->
<div v-else class="dash-alert dash-alert--salary">
<span class="fc-blink" style="color:var(--fc-gold)"></span>
<span class="fc-pixel" style="font-size:8px">
SALÁRIO PENDENTE {{ pendingIncome.map(i => i.name).join(', ') }}
</span>
</div>
</template>
<div class="dash-grid">
<!-- Left column -->
<div class="dash-col-main">
@@ -289,6 +338,25 @@ const savingsOk = computed(() => savingsPct.value >= 40)
.empty { font-size: 8px; color: var(--fc-text-dim); padding: 8px 0; }
/* Income widget */
.dash-income-panel { margin-bottom: 0; }
.dash-alert--salary { background: rgba(255,180,0,.08); border-color: var(--fc-gold); }
.income-list { display: flex; flex-direction: column; gap: 10px; }
.income-item {
display: flex; align-items: center; justify-content: space-between;
gap: var(--fc-space-3); padding: 10px var(--fc-space-3);
border: 1px solid var(--fc-panel-edge); border-radius: var(--fc-radius);
flex-wrap: wrap;
}
.income-item__info { display: flex; align-items: center; gap: var(--fc-space-3); }
.income-item__name { font-size: 14px; font-weight: 500; }
.income-item__amount { font-size: 13px; font-weight: 700; }
.income-item__actions { display: flex; gap: var(--fc-space-1); align-items: center; }
.income-chip--late {
font-size: 7px; padding: 3px 6px; border-radius: 2px;
background: var(--fc-accent); color: var(--fc-bg);
}
@media (max-width: 767px) {
.dash-page { padding: 14px 12px 80px; }
.tx-row { grid-template-columns: 70px 1fr 80px; }
+293
View File
@@ -0,0 +1,293 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRecurringStore } from '@/stores/recurring'
import { useCategoriesStore } from '@/stores/categories'
import NeonPanel from '@/components/NeonPanel.vue'
const store = useRecurringStore()
const catStore = useCategoriesStore()
const currentMonth = ref(new Date().toISOString().slice(0, 7))
onMounted(async () => {
await Promise.all([store.fetchAll(), catStore.fetchAll()])
await store.fetchMonthlyStatus(currentMonth.value)
})
const blank = (): Omit<typeof store.items[0], 'id' | 'active' | 'created_at' | 'updated_at'> => ({
name: '',
expected_amount: 0,
day_of_month: 1,
category_id: null,
type: 'expense',
})
const form = ref(blank())
const editId = ref<number | null>(null)
const amountRaw = ref('')
const formError = ref<string | null>(null)
function parseAmount(s: string) {
return parseFloat(s.replace(/\./g, '').replace(',', '.')) || 0
}
function startEdit(id: number) {
const item = store.items.find((x) => x.id === id)
if (!item) return
editId.value = id
form.value = { name: item.name, expected_amount: item.expected_amount, day_of_month: item.day_of_month, category_id: item.category_id, type: item.type }
amountRaw.value = item.expected_amount.toLocaleString('pt-BR', { minimumFractionDigits: 2 })
formError.value = null
}
function cancelEdit() {
editId.value = null
form.value = blank()
amountRaw.value = ''
formError.value = null
}
async function submit() {
formError.value = null
form.value.expected_amount = parseAmount(amountRaw.value)
try {
if (editId.value !== null) {
await store.update(editId.value, form.value)
cancelEdit()
} else {
await store.create(form.value)
form.value = blank()
amountRaw.value = ''
}
await store.fetchMonthlyStatus(currentMonth.value)
} catch (e: any) {
formError.value = e.message
}
}
async function remove(id: number, name: string) {
if (!confirm(`Excluir recorrência "${name}"?`)) return
await store.remove(id)
}
async function confirmIncome(id: number) {
await store.confirmIncome(id, currentMonth.value)
}
async function markLate(id: number) {
await store.markLate(id, currentMonth.value)
}
function catName(id: number | null) {
if (!id) return '—'
return catStore.categories.find((c) => c.id === id)?.name ?? '—'
}
function fmt(v: number) {
return v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })
}
const incomeItems = computed(() => store.items.filter((x) => x.type === 'income'))
const expenseItems = computed(() => store.items.filter((x) => x.type === 'expense'))
function getStatus(id: number) {
return store.monthlyStatus.find((s) => s.id === id)
}
</script>
<template>
<div class="fc-view">
<span class="fc-pixel fc-view__title">:: RECORRÊNCIAS</span>
<!-- Form -->
<NeonPanel :title="editId !== null ? 'EDITAR RECORRÊNCIA' : 'NOVA RECORRÊNCIA'">
<form class="rec-form" @submit.prevent="submit">
<div class="rec-form__row">
<input v-model="form.name" placeholder="Nome (ex: Salário, Netflix)" required class="fc-input rec-form__name" />
<input v-model="amountRaw" placeholder="3.200,00" required class="fc-input rec-form__amount" />
<div class="fc-label rec-form__day-wrap">
Dia
<input type="number" v-model.number="form.day_of_month" min="1" max="31" class="fc-input rec-form__day" />
</div>
<select v-model="form.category_id" class="fc-select rec-form__cat">
<option :value="null">Sem categoria</option>
<option v-for="c in catStore.categories" :key="c.id" :value="c.id">{{ c.name }}</option>
</select>
<select v-model="form.type" class="fc-select rec-form__type">
<option value="expense">DESPESA</option>
<option value="income">RECEITA</option>
</select>
</div>
<p v-if="formError" class="rec-form__error fc-mono">{{ formError }}</p>
<div class="rec-form__actions">
<button type="submit" class="fc-btn fc-btn--primary">{{ editId !== null ? 'SALVAR' : 'ADICIONAR' }}</button>
<button v-if="editId !== null" type="button" class="fc-btn fc-btn--ghost" @click="cancelEdit">CANCELAR</button>
</div>
</form>
</NeonPanel>
<!-- RECEITAS -->
<NeonPanel title="RECEITAS">
<p v-if="store.loading" class="rec-empty fc-mono">carregando...</p>
<ul v-else class="rec-list">
<li v-for="item in incomeItems" :key="item.id" class="rec-item" :class="{ 'rec-item--editing': editId === item.id }">
<div class="rec-item__info">
<span class="fc-body rec-item__name">{{ item.name }}</span>
<span class="fc-mono rec-item__meta">
Todo dia {{ item.day_of_month }} · {{ fmt(item.expected_amount) }} · {{ catName(item.category_id) }}
</span>
</div>
<div class="rec-item__right">
<!-- Monthly status chip -->
<template v-if="getStatus(item.id)">
<span v-if="getStatus(item.id)!.covered" class="fc-chip chip--ok fc-pixel">CONFIRMADO</span>
<template v-else-if="getStatus(item.id)!.late">
<span class="fc-chip chip--late fc-pixel">ATRASOU</span>
</template>
<template v-else>
<button class="fc-btn fc-btn--sm fc-btn--primary" @click="confirmIncome(item.id)">CONFIRMAR</button>
<button class="fc-btn fc-btn--sm fc-btn--ghost" @click="markLate(item.id)">ATRASOU</button>
</template>
</template>
<div class="rec-item__actions">
<button class="fc-btn fc-btn--sm fc-btn--ghost" @click="startEdit(item.id)">EDITAR</button>
<button class="fc-btn fc-btn--sm fc-btn--danger" @click="remove(item.id, item.name)"></button>
</div>
</div>
</li>
<li v-if="incomeItems.length === 0" class="rec-empty fc-mono"> nenhuma receita recorrente cadastrada </li>
</ul>
</NeonPanel>
<!-- DESPESAS -->
<NeonPanel title="DESPESAS">
<p v-if="store.loading" class="rec-empty fc-mono">carregando...</p>
<ul v-else class="rec-list">
<li v-for="item in expenseItems" :key="item.id" class="rec-item" :class="{ 'rec-item--editing': editId === item.id }">
<div class="rec-item__info">
<span class="fc-body rec-item__name">{{ item.name }}</span>
<span class="fc-mono rec-item__meta">
Todo dia {{ item.day_of_month }} · {{ fmt(item.expected_amount) }} · {{ catName(item.category_id) }}
</span>
</div>
<div class="rec-item__right">
<template v-if="getStatus(item.id)">
<span v-if="getStatus(item.id)!.covered" class="fc-chip chip--ok fc-pixel">OK</span>
<span v-else class="fc-chip chip--pending fc-pixel">PENDENTE</span>
</template>
<div class="rec-item__actions">
<button class="fc-btn fc-btn--sm fc-btn--ghost" @click="startEdit(item.id)">EDITAR</button>
<button class="fc-btn fc-btn--sm fc-btn--danger" @click="remove(item.id, item.name)"></button>
</div>
</div>
</li>
<li v-if="expenseItems.length === 0" class="rec-empty fc-mono"> nenhuma despesa recorrente cadastrada </li>
</ul>
</NeonPanel>
</div>
</template>
<style scoped>
.fc-view {
max-width: 640px;
margin: 0 auto;
padding: var(--fc-space-4);
display: flex;
flex-direction: column;
gap: var(--fc-space-4);
padding-bottom: 96px;
}
.fc-view__title {
font-size: 11px;
color: var(--fc-accent-2);
}
.rec-form__row {
display: flex;
gap: var(--fc-space-2);
flex-wrap: wrap;
align-items: flex-end;
}
.rec-form__name { flex: 1; min-width: 150px; }
.rec-form__amount { width: 110px; }
.rec-form__day-wrap { width: 70px; flex-shrink: 0; }
.rec-form__day { width: 100%; }
.rec-form__cat { width: 150px; }
.rec-form__type { width: 110px; }
.rec-form__error {
color: var(--fc-red);
font-size: 11px;
margin-top: var(--fc-space-2);
}
.rec-form__actions {
display: flex;
gap: var(--fc-space-2);
margin-top: var(--fc-space-3);
}
.rec-empty {
font-size: 11px;
color: var(--fc-text-dim);
text-align: center;
padding: var(--fc-space-4) 0;
}
.rec-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: var(--fc-space-2);
}
.rec-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--fc-space-3);
padding: 12px var(--fc-space-3);
border: 1px solid var(--fc-panel-edge);
border-radius: var(--fc-radius);
flex-wrap: wrap;
transition: border-color .15s;
}
.rec-item--editing {
border-color: var(--fc-accent-3);
box-shadow: 0 0 8px rgba(168,85,247,.3);
}
.rec-item__info {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
}
.rec-item__name { font-size: 14px; font-weight: 500; }
.rec-item__meta { font-size: 11px; color: var(--fc-text-dim); }
.rec-item__right {
display: flex;
align-items: center;
gap: var(--fc-space-2);
flex-wrap: wrap;
}
.rec-item__actions { display: flex; gap: var(--fc-space-1); }
.fc-chip {
font-size: 7px;
padding: 3px 6px;
border-radius: 2px;
letter-spacing: .05em;
}
.chip--ok { background: rgba(34,197,94,.2); color: var(--fc-green); border: 1px solid var(--fc-green); }
.chip--late { background: rgba(255,180,0,.2); color: var(--fc-gold); border: 1px solid var(--fc-gold); }
.chip--pending { background: rgba(255,59,107,.1); color: var(--fc-red); border: 1px solid var(--fc-red); }
</style>