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:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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{})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user