diff --git a/apps/api/cmd/server/main.go b/apps/api/cmd/server/main.go index cdeb619..b819d08 100644 --- a/apps/api/cmd/server/main.go +++ b/apps/api/cmd/server/main.go @@ -56,6 +56,14 @@ func main() { importSvc := service.NewImportService(transactionRepo) importHandler := handler.NewImportHandler(importSvc) + manualTxRepo := repository.NewManualTransactionRepository(pool) + txSvc := service.NewTransactionService(manualTxRepo) + txHandler := handler.NewTransactionHandler(txSvc) + + recurringRepo := repository.NewRecurringRepository(pool) + recurringSvc := service.NewRecurringService(recurringRepo, manualTxRepo) + recurringHandler := handler.NewRecurringHandler(recurringSvc) + r.Get("/health", handler.Health) r.Route("/api", func(r chi.Router) { @@ -66,6 +74,19 @@ func main() { r.Post("/imports/preview", importHandler.Preview) r.Post("/imports/confirm", importHandler.Confirm) + + r.Get("/transactions", txHandler.List) + r.Post("/transactions", txHandler.Create) + r.Put("/transactions/{id}", txHandler.Update) + r.Delete("/transactions/{id}", txHandler.Delete) + + r.Get("/recurring", recurringHandler.List) + r.Post("/recurring", recurringHandler.Create) + r.Put("/recurring/{id}", recurringHandler.Update) + r.Delete("/recurring/{id}", recurringHandler.Delete) + r.Get("/recurring/status", recurringHandler.MonthlyStatus) + r.Post("/recurring/{id}/ignore", recurringHandler.Ignore) + r.Delete("/recurring/{id}/ignore", recurringHandler.Unignore) }) // Serve Vue SPA — non-API routes fall through to index.html diff --git a/apps/api/internal/handler/recurring.go b/apps/api/internal/handler/recurring.go new file mode 100644 index 0000000..578bc1d --- /dev/null +++ b/apps/api/internal/handler/recurring.go @@ -0,0 +1,130 @@ +package handler + +import ( + "encoding/json" + "errors" + "net/http" + "strconv" + + "github.com/go-chi/chi/v5" + + "financeiro-carvalho/internal/model" + "financeiro-carvalho/internal/repository" + "financeiro-carvalho/internal/service" +) + +type RecurringHandler struct { + svc *service.RecurringService +} + +func NewRecurringHandler(svc *service.RecurringService) *RecurringHandler { + return &RecurringHandler{svc: svc} +} + +func (h *RecurringHandler) List(w http.ResponseWriter, r *http.Request) { + items, err := h.svc.List(r.Context()) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to list recurring expenses") + return + } + respondJSON(w, http.StatusOK, items) +} + +func (h *RecurringHandler) Create(w http.ResponseWriter, r *http.Request) { + var in model.RecurringInput + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + respondError(w, http.StatusBadRequest, "invalid JSON") + return + } + out, err := h.svc.Create(r.Context(), in) + if err != nil { + respondError(w, http.StatusBadRequest, err.Error()) + return + } + respondJSON(w, http.StatusCreated, out) +} + +func (h *RecurringHandler) Update(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 in model.RecurringInput + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + respondError(w, http.StatusBadRequest, "invalid JSON") + return + } + out, err := h.svc.Update(r.Context(), id, in) + if errors.Is(err, repository.ErrNotFound) { + respondError(w, http.StatusNotFound, "not found") + return + } + if err != nil { + respondError(w, http.StatusBadRequest, err.Error()) + return + } + respondJSON(w, http.StatusOK, out) +} + +func (h *RecurringHandler) Delete(w http.ResponseWriter, r *http.Request) { + id, err := strconv.Atoi(chi.URLParam(r, "id")) + if err != nil { + respondError(w, http.StatusBadRequest, "invalid id") + return + } + if err := h.svc.Delete(r.Context(), id); err != nil { + respondError(w, http.StatusNotFound, "not found") + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (h *RecurringHandler) MonthlyStatus(w http.ResponseWriter, r *http.Request) { + month := r.URL.Query().Get("month") + if month == "" { + respondError(w, http.StatusBadRequest, "month param required (YYYY-MM)") + return + } + statuses, err := h.svc.MonthlyStatus(r.Context(), month) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to compute status") + return + } + respondJSON(w, http.StatusOK, statuses) +} + +func (h *RecurringHandler) Ignore(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"` + Reason string `json:"reason"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + respondError(w, http.StatusBadRequest, "invalid JSON") + return + } + if err := h.svc.Ignore(r.Context(), id, body.Month, body.Reason); err != nil { + respondError(w, http.StatusInternalServerError, "failed to ignore") + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (h *RecurringHandler) Unignore(w http.ResponseWriter, r *http.Request) { + id, err := strconv.Atoi(chi.URLParam(r, "id")) + if err != nil { + respondError(w, http.StatusBadRequest, "invalid id") + return + } + month := r.URL.Query().Get("month") + if err := h.svc.Unignore(r.Context(), id, month); err != nil { + respondError(w, http.StatusInternalServerError, "failed to unignore") + return + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/apps/api/internal/handler/transaction.go b/apps/api/internal/handler/transaction.go new file mode 100644 index 0000000..f344447 --- /dev/null +++ b/apps/api/internal/handler/transaction.go @@ -0,0 +1,83 @@ +package handler + +import ( + "encoding/json" + "errors" + "net/http" + "strconv" + + "github.com/go-chi/chi/v5" + + "financeiro-carvalho/internal/model" + "financeiro-carvalho/internal/repository" + "financeiro-carvalho/internal/service" +) + +type TransactionHandler struct { + svc *service.TransactionService +} + +func NewTransactionHandler(svc *service.TransactionService) *TransactionHandler { + return &TransactionHandler{svc: svc} +} + +func (h *TransactionHandler) List(w http.ResponseWriter, r *http.Request) { + month := r.URL.Query().Get("month") + txs, err := h.svc.List(r.Context(), month) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to list transactions") + return + } + respondJSON(w, http.StatusOK, txs) +} + +func (h *TransactionHandler) Create(w http.ResponseWriter, r *http.Request) { + var t model.Transaction + if err := json.NewDecoder(r.Body).Decode(&t); err != nil { + respondError(w, http.StatusBadRequest, "invalid JSON") + return + } + out, err := h.svc.Create(r.Context(), t) + if err != nil { + respondError(w, http.StatusBadRequest, err.Error()) + return + } + respondJSON(w, http.StatusCreated, out) +} + +func (h *TransactionHandler) Update(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 t model.Transaction + if err := json.NewDecoder(r.Body).Decode(&t); err != nil { + respondError(w, http.StatusBadRequest, "invalid JSON") + return + } + t.ID = id + out, err := h.svc.Update(r.Context(), t) + if errors.Is(err, repository.ErrNotFound) || errors.Is(err, service.ErrCannotEditImported) { + respondError(w, http.StatusNotFound, err.Error()) + return + } + if err != nil { + respondError(w, http.StatusBadRequest, err.Error()) + return + } + respondJSON(w, http.StatusOK, out) +} + +func (h *TransactionHandler) Delete(w http.ResponseWriter, r *http.Request) { + id, err := strconv.Atoi(chi.URLParam(r, "id")) + if err != nil { + respondError(w, http.StatusBadRequest, "invalid id") + return + } + if err := h.svc.Delete(r.Context(), id); err != nil { + respondError(w, http.StatusNotFound, err.Error()) + return + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/apps/api/internal/migration/migration.go b/apps/api/internal/migration/migration.go index 4ea9769..190239b 100644 --- a/apps/api/internal/migration/migration.go +++ b/apps/api/internal/migration/migration.go @@ -14,15 +14,13 @@ var m001 string //go:embed sql/002_add_external_id.sql var m002 string +//go:embed sql/003_recurring_ignores.sql +var m003 string + func Run(ctx context.Context, pool *pgxpool.Pool) error { - for i, sql := range []struct { - version int - sql string - }{ - {1, m001}, - {2, m002}, - } { - if _, err := pool.Exec(ctx, sql.sql); err != nil { + migrations := []string{m001, m002, m003} + for i, sql := range migrations { + if _, err := pool.Exec(ctx, sql); err != nil { return fmt.Errorf("migration %03d: %w", i+1, err) } } diff --git a/apps/api/internal/migration/sql/003_recurring_ignores.sql b/apps/api/internal/migration/sql/003_recurring_ignores.sql new file mode 100644 index 0000000..b5fb303 --- /dev/null +++ b/apps/api/internal/migration/sql/003_recurring_ignores.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS recurring_ignores ( + id SERIAL PRIMARY KEY, + recurring_id INTEGER NOT NULL REFERENCES recurring_expenses (id) ON DELETE CASCADE, + month CHAR(7) NOT NULL, -- YYYY-MM + reason TEXT, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + UNIQUE (recurring_id, month) +); + +INSERT INTO schema_migrations (version) VALUES (3) ON CONFLICT DO NOTHING; diff --git a/apps/api/internal/model/recurring.go b/apps/api/internal/model/recurring.go new file mode 100644 index 0000000..2827ed2 --- /dev/null +++ b/apps/api/internal/model/recurring.go @@ -0,0 +1,29 @@ +package model + +import "time" + +type RecurringExpense struct { + ID int `json:"id"` + Name string `json:"name"` + ExpectedAmount float64 `json:"expected_amount"` + DayOfMonth int `json:"day_of_month"` + CategoryID *int `json:"category_id"` + Active bool `json:"active"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type RecurringInput struct { + Name string `json:"name"` + ExpectedAmount float64 `json:"expected_amount"` + DayOfMonth int `json:"day_of_month"` + CategoryID *int `json:"category_id"` +} + +// RecurringStatus reports whether a recurring expense is covered for a month. +type RecurringStatus struct { + RecurringExpense + Covered bool `json:"covered"` // has a matching transaction + Ignored bool `json:"ignored"` // user explicitly ignored this month + Reason string `json:"reason,omitempty"` +} diff --git a/apps/api/internal/repository/recurring.go b/apps/api/internal/repository/recurring.go new file mode 100644 index 0000000..590658a --- /dev/null +++ b/apps/api/internal/repository/recurring.go @@ -0,0 +1,121 @@ +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 +} + +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, 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.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, 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) + 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) { + 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) + return &re, err +} + +func (r *recurringRepo) Update(ctx context.Context, id int, in model.RecurringInput) (*model.RecurringExpense, error) { + 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) + 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 +} diff --git a/apps/api/internal/repository/transaction_manual.go b/apps/api/internal/repository/transaction_manual.go new file mode 100644 index 0000000..6a58915 --- /dev/null +++ b/apps/api/internal/repository/transaction_manual.go @@ -0,0 +1,122 @@ +package repository + +import ( + "context" + "errors" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "financeiro-carvalho/internal/model" +) + +type ManualTransactionRepository interface { + List(ctx context.Context, month string) ([]model.Transaction, error) + GetByID(ctx context.Context, id int) (*model.Transaction, error) + 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) +} + +type manualTxRepo struct{ db *pgxpool.Pool } + +func NewManualTransactionRepository(db *pgxpool.Pool) ManualTransactionRepository { + return &manualTxRepo{db: db} +} + +func (r *manualTxRepo) List(ctx context.Context, month string) ([]model.Transaction, error) { + query := ` + SELECT id, date::text, amount, description, type, source, category_id, created_at, updated_at + FROM transactions` + args := []any{} + if month != "" { + query += ` WHERE TO_CHAR(date, 'YYYY-MM') = $1` + args = append(args, month) + } + query += ` ORDER BY date DESC, id DESC` + + rows, err := r.db.Query(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []model.Transaction + for rows.Next() { + var t model.Transaction + if err := rows.Scan(&t.ID, &t.Date, &t.Amount, &t.Description, &t.Type, &t.Source, &t.CategoryID, &t.CreatedAt, &t.UpdatedAt); err != nil { + return nil, err + } + out = append(out, t) + } + return out, rows.Err() +} + +func (r *manualTxRepo) GetByID(ctx context.Context, id int) (*model.Transaction, error) { + var t model.Transaction + err := r.db.QueryRow(ctx, ` + SELECT id, date::text, amount, description, type, source, category_id, created_at, updated_at + FROM transactions WHERE id = $1`, id). + Scan(&t.ID, &t.Date, &t.Amount, &t.Description, &t.Type, &t.Source, &t.CategoryID, &t.CreatedAt, &t.UpdatedAt) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + return &t, err +} + +func (r *manualTxRepo) Create(ctx context.Context, t model.Transaction) (*model.Transaction, error) { + var out model.Transaction + err := r.db.QueryRow(ctx, ` + INSERT INTO transactions (date, amount, description, type, source, category_id) + VALUES ($1, $2, $3, $4, 'manual', $5) + RETURNING id, date::text, amount, description, type, source, category_id, created_at, updated_at`, + t.Date, t.Amount, t.Description, t.Type, t.CategoryID). + Scan(&out.ID, &out.Date, &out.Amount, &out.Description, &out.Type, &out.Source, &out.CategoryID, &out.CreatedAt, &out.UpdatedAt) + return &out, err +} + +func (r *manualTxRepo) Update(ctx context.Context, t model.Transaction) (*model.Transaction, error) { + var out model.Transaction + err := r.db.QueryRow(ctx, ` + UPDATE transactions + SET date = $1, amount = $2, description = $3, type = $4, category_id = $5, updated_at = NOW() + WHERE id = $6 AND source = 'manual' + RETURNING id, date::text, amount, description, type, source, category_id, created_at, updated_at`, + t.Date, t.Amount, t.Description, t.Type, t.CategoryID, t.ID). + Scan(&out.ID, &out.Date, &out.Amount, &out.Description, &out.Type, &out.Source, &out.CategoryID, &out.CreatedAt, &out.UpdatedAt) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + return &out, err +} + +func (r *manualTxRepo) Delete(ctx context.Context, id int) error { + tag, err := r.db.Exec(ctx, `DELETE FROM transactions WHERE id = $1 AND source = 'manual'`, id) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + 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 { + // No category set — cannot auto-match, always report as uncovered + return false, nil + } + return count > 0, err +} diff --git a/apps/api/internal/service/recurring.go b/apps/api/internal/service/recurring.go new file mode 100644 index 0000000..71d9932 --- /dev/null +++ b/apps/api/internal/service/recurring.go @@ -0,0 +1,107 @@ +package service + +import ( + "context" + "errors" + "strings" + + "financeiro-carvalho/internal/model" + "financeiro-carvalho/internal/repository" +) + +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") +) + +type RecurringService struct { + repo repository.RecurringRepository + txRepo repository.ManualTransactionRepository +} + +func NewRecurringService(repo repository.RecurringRepository, txRepo repository.ManualTransactionRepository) *RecurringService { + return &RecurringService{repo: repo, txRepo: txRepo} +} + +func (s *RecurringService) List(ctx context.Context) ([]model.RecurringExpense, error) { + items, err := s.repo.List(ctx) + if items == nil { + return []model.RecurringExpense{}, err + } + return items, err +} + +func (s *RecurringService) Create(ctx context.Context, in model.RecurringInput) (*model.RecurringExpense, error) { + if err := validateRecurringInput(in); err != nil { + return nil, err + } + in.Name = strings.TrimSpace(in.Name) + return s.repo.Create(ctx, in) +} + +func (s *RecurringService) Update(ctx context.Context, id int, in model.RecurringInput) (*model.RecurringExpense, error) { + if err := validateRecurringInput(in); err != nil { + return nil, err + } + in.Name = strings.TrimSpace(in.Name) + return s.repo.Update(ctx, id, in) +} + +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). +func (s *RecurringService) MonthlyStatus(ctx context.Context, month string) ([]model.RecurringStatus, error) { + items, err := s.repo.List(ctx) + if err != nil { + return nil, err + } + + var result []model.RecurringStatus + for _, re := range items { + if !re.Active { + continue + } + 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) + if err != nil { + return nil, err + } + } + result = append(result, model.RecurringStatus{ + RecurringExpense: re, + Covered: covered || ignored, + Ignored: ignored, + Reason: reason, + }) + } + return result, nil +} + +func (s *RecurringService) Ignore(ctx context.Context, id int, month, reason string) error { + return s.repo.Ignore(ctx, id, month, reason) +} + +func (s *RecurringService) Unignore(ctx context.Context, id int, month string) error { + return s.repo.Unignore(ctx, id, month) +} + +func validateRecurringInput(in model.RecurringInput) error { + if strings.TrimSpace(in.Name) == "" { + return ErrRecurringEmptyName + } + if in.ExpectedAmount <= 0 { + return ErrRecurringInvalidAmount + } + if in.DayOfMonth < 1 || in.DayOfMonth > 31 { + return ErrRecurringInvalidDayOfMonth + } + return nil +} diff --git a/apps/api/internal/service/recurring_test.go b/apps/api/internal/service/recurring_test.go new file mode 100644 index 0000000..5e0d814 --- /dev/null +++ b/apps/api/internal/service/recurring_test.go @@ -0,0 +1,153 @@ +package service_test + +import ( + "context" + "testing" + + "financeiro-carvalho/internal/model" + "financeiro-carvalho/internal/repository" + "financeiro-carvalho/internal/service" +) + +// ── mock recurring repo ────────────────────────────────────────────────────── + +type mockRecurringRepo struct { + items []model.RecurringExpense + ignores map[string]string // "id:month" → reason +} + +func newMockRecurring(items []model.RecurringExpense) *mockRecurringRepo { + return &mockRecurringRepo{items: items, ignores: map[string]string{}} +} + +func (m *mockRecurringRepo) List(_ context.Context) ([]model.RecurringExpense, error) { + return m.items, nil +} +func (m *mockRecurringRepo) GetByID(_ context.Context, id int) (*model.RecurringExpense, error) { + for _, r := range m.items { + if r.ID == id { + cp := r + return &cp, nil + } + } + 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} + m.items = append(m.items, r) + return &r, nil +} +func (m *mockRecurringRepo) Update(_ context.Context, id int, in model.RecurringInput) (*model.RecurringExpense, error) { + for i, r := range m.items { + if r.ID == id { + m.items[i].Name = in.Name + m.items[i].ExpectedAmount = in.ExpectedAmount + cp := m.items[i] + return &cp, nil + } + } + return nil, repository.ErrNotFound +} +func (m *mockRecurringRepo) Delete(_ context.Context, id int) error { return nil } +func (m *mockRecurringRepo) IsIgnored(_ context.Context, id int, month string) (bool, string, error) { + key := string(rune(id)) + ":" + month + r, ok := m.ignores[key] + return ok, r, nil +} +func (m *mockRecurringRepo) Ignore(_ context.Context, id int, month, reason string) error { + m.ignores[string(rune(id))+":"+month] = reason + return nil +} +func (m *mockRecurringRepo) Unignore(_ context.Context, id int, month string) error { + delete(m.ignores, string(rune(id))+":"+month) + return nil +} + +// ── mock tx repo ───────────────────────────────────────────────────────────── + +type mockTxRepo struct { + matchResult bool +} + +func (m *mockTxRepo) List(_ context.Context, _ string) ([]model.Transaction, error) { return nil, nil } +func (m *mockTxRepo) GetByID(_ context.Context, _ int) (*model.Transaction, error) { return nil, nil } +func (m *mockTxRepo) Create(_ context.Context, t model.Transaction) (*model.Transaction, error) { + return &t, nil +} +func (m *mockTxRepo) Update(_ context.Context, t model.Transaction) (*model.Transaction, error) { + return &t, nil +} +func (m *mockTxRepo) Delete(_ context.Context, _ int) error { return nil } +func (m *mockTxRepo) HasMatchingTransaction(_ context.Context, _ *int, _ string, _ float64) (bool, error) { + return m.matchResult, nil +} + +// ── tests ───────────────────────────────────────────────────────────────────── + +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} + 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("expected Covered=true") + } +} + +func TestMonthlyStatus_Uncovered(t *testing.T) { + re := model.RecurringExpense{ID: 2, Name: "Netflix", ExpectedAmount: 55, DayOfMonth: 10, CategoryID: &catID, Active: true} + svc := service.NewRecurringService(newMockRecurring([]model.RecurringExpense{re}), &mockTxRepo{matchResult: false}) + + statuses, err := svc.MonthlyStatus(context.Background(), "2024-03") + if err != nil { + t.Fatal(err) + } + if statuses[0].Covered { + t.Error("expected Covered=false") + } +} + +func TestMonthlyStatus_Ignored_CountsAsCovered(t *testing.T) { + re := model.RecurringExpense{ID: 3, Name: "Seguro", ExpectedAmount: 200, DayOfMonth: 1, CategoryID: &catID, Active: true} + repo := newMockRecurring([]model.RecurringExpense{re}) + _ = repo.Ignore(context.Background(), 3, "2024-03", "viagem") + 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("ignored should count as covered") + } + if !statuses[0].Ignored { + t.Error("expected Ignored=true") + } +} + +func TestCreateRecurring_Validation(t *testing.T) { + svc := service.NewRecurringService(newMockRecurring(nil), &mockTxRepo{}) + + _, err := svc.Create(context.Background(), model.RecurringInput{Name: "", ExpectedAmount: 100, DayOfMonth: 5}) + if err != service.ErrRecurringEmptyName { + t.Fatalf("expected ErrRecurringEmptyName, got %v", err) + } + + _, err = svc.Create(context.Background(), model.RecurringInput{Name: "X", ExpectedAmount: -1, DayOfMonth: 5}) + if err != service.ErrRecurringInvalidAmount { + t.Fatalf("expected ErrRecurringInvalidAmount, got %v", err) + } + + _, err = svc.Create(context.Background(), model.RecurringInput{Name: "X", ExpectedAmount: 100, DayOfMonth: 0}) + if err != service.ErrRecurringInvalidDayOfMonth { + t.Fatalf("expected ErrRecurringInvalidDayOfMonth, got %v", err) + } +} diff --git a/apps/api/internal/service/transaction.go b/apps/api/internal/service/transaction.go new file mode 100644 index 0000000..6b56561 --- /dev/null +++ b/apps/api/internal/service/transaction.go @@ -0,0 +1,74 @@ +package service + +import ( + "context" + "errors" + "strings" + + "financeiro-carvalho/internal/model" + "financeiro-carvalho/internal/repository" +) + +var ( + ErrInvalidTransactionType = errors.New("type must be 'income' or 'expense'") + ErrCannotEditImported = errors.New("imported transactions cannot be edited or deleted") + ErrTransactionEmptyDesc = errors.New("description is required") + ErrTransactionInvalidAmount = errors.New("amount must be greater than zero") +) + +type TransactionService struct { + repo repository.ManualTransactionRepository +} + +func NewTransactionService(repo repository.ManualTransactionRepository) *TransactionService { + return &TransactionService{repo: repo} +} + +func (s *TransactionService) List(ctx context.Context, month string) ([]model.Transaction, error) { + txs, err := s.repo.List(ctx, month) + if txs == nil { + return []model.Transaction{}, err + } + return txs, err +} + +func (s *TransactionService) Create(ctx context.Context, t model.Transaction) (*model.Transaction, error) { + if err := validateTransaction(t); err != nil { + return nil, err + } + t.Description = strings.TrimSpace(t.Description) + return s.repo.Create(ctx, t) +} + +func (s *TransactionService) Update(ctx context.Context, t model.Transaction) (*model.Transaction, error) { + if err := validateTransaction(t); err != nil { + return nil, err + } + t.Description = strings.TrimSpace(t.Description) + out, err := s.repo.Update(ctx, t) + if errors.Is(err, repository.ErrNotFound) { + return nil, ErrCannotEditImported + } + return out, err +} + +func (s *TransactionService) Delete(ctx context.Context, id int) error { + err := s.repo.Delete(ctx, id) + if errors.Is(err, repository.ErrNotFound) { + return ErrCannotEditImported + } + return err +} + +func validateTransaction(t model.Transaction) error { + if strings.TrimSpace(t.Description) == "" { + return ErrTransactionEmptyDesc + } + if t.Amount <= 0 { + return ErrTransactionInvalidAmount + } + if t.Type != "income" && t.Type != "expense" { + return ErrInvalidTransactionType + } + return nil +} diff --git a/apps/web/src/App.vue b/apps/web/src/App.vue index 75efbdf..4d2b5af 100644 --- a/apps/web/src/App.vue +++ b/apps/web/src/App.vue @@ -7,7 +7,9 @@ diff --git a/apps/web/src/router/index.ts b/apps/web/src/router/index.ts index 7a98f5c..7b42dc6 100644 --- a/apps/web/src/router/index.ts +++ b/apps/web/src/router/index.ts @@ -19,6 +19,16 @@ const router = createRouter({ name: 'import', component: () => import('../views/ImportView.vue'), }, + { + path: '/transacoes', + name: 'transactions', + component: () => import('../views/TransactionsView.vue'), + }, + { + path: '/configuracoes', + name: 'settings', + component: () => import('../views/SettingsView.vue'), + }, ], }) diff --git a/apps/web/src/stores/recurring.ts b/apps/web/src/stores/recurring.ts new file mode 100644 index 0000000..45cc722 --- /dev/null +++ b/apps/web/src/stores/recurring.ts @@ -0,0 +1,81 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { api } from '@/services/api' +import type { Category } from './categories' + +export interface RecurringExpense { + id: number + name: string + expected_amount: number + day_of_month: number + category_id: number | null + active: boolean + created_at: string + updated_at: string +} + +export interface RecurringStatus extends RecurringExpense { + covered: boolean + ignored: boolean + reason?: string +} + +export const useRecurringStore = defineStore('recurring', () => { + const items = ref([]) + const monthlyStatus = ref([]) + const loading = ref(false) + const error = ref(null) + + async function fetchAll() { + loading.value = true + error.value = null + try { + items.value = await api.get('/recurring') + } catch (e: any) { + error.value = e.message + } finally { + loading.value = false + } + } + + async function fetchMonthlyStatus(month: string) { + try { + monthlyStatus.value = await api.get(`/recurring/status?month=${month}`) + } catch { + monthlyStatus.value = [] + } + } + + async function create(input: Omit) { + const item = await api.post('/recurring', input) + items.value.push(item) + return item + } + + async function update(id: number, input: Omit) { + const item = await api.put(`/recurring/${id}`, input) + const idx = items.value.findIndex((x) => x.id === id) + if (idx !== -1) items.value[idx] = item + return item + } + + async function remove(id: number) { + await api.delete(`/recurring/${id}`) + items.value = items.value.filter((x) => x.id !== id) + } + + async function ignore(id: number, month: string, reason: string) { + await api.post(`/recurring/${id}/ignore`, { month, reason }) + await fetchMonthlyStatus(month) + } + + async function unignore(id: number, month: string) { + await api.delete(`/recurring/${id}/ignore?month=${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 } +}) diff --git a/apps/web/src/stores/transactions.ts b/apps/web/src/stores/transactions.ts new file mode 100644 index 0000000..f594abe --- /dev/null +++ b/apps/web/src/stores/transactions.ts @@ -0,0 +1,62 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { api } from '@/services/api' + +export interface Transaction { + id: number + date: string + amount: number + description: string + type: 'income' | 'expense' + source: 'manual' | 'import' + category_id: number | null + created_at: string + updated_at: string +} + +export interface TransactionInput { + date: string + amount: number + description: string + type: 'income' | 'expense' + category_id: number | null +} + +export const useTransactionsStore = defineStore('transactions', () => { + const transactions = ref([]) + const loading = ref(false) + const error = ref(null) + + async function fetchAll(month?: string) { + loading.value = true + error.value = null + try { + const q = month ? `?month=${month}` : '' + transactions.value = await api.get(`/transactions${q}`) + } catch (e: any) { + error.value = e.message + } finally { + loading.value = false + } + } + + async function create(input: TransactionInput) { + const t = await api.post('/transactions', input) + transactions.value.unshift(t) + return t + } + + async function update(id: number, input: TransactionInput) { + const t = await api.put(`/transactions/${id}`, input) + const idx = transactions.value.findIndex((x) => x.id === id) + if (idx !== -1) transactions.value[idx] = t + return t + } + + async function remove(id: number) { + await api.delete(`/transactions/${id}`) + transactions.value = transactions.value.filter((x) => x.id !== id) + } + + return { transactions, loading, error, fetchAll, create, update, remove } +}) diff --git a/apps/web/src/views/SettingsView.vue b/apps/web/src/views/SettingsView.vue new file mode 100644 index 0000000..e8e87c3 --- /dev/null +++ b/apps/web/src/views/SettingsView.vue @@ -0,0 +1,138 @@ + + + + + diff --git a/apps/web/src/views/TransactionsView.vue b/apps/web/src/views/TransactionsView.vue new file mode 100644 index 0000000..1af6b07 --- /dev/null +++ b/apps/web/src/views/TransactionsView.vue @@ -0,0 +1,202 @@ + + + + +