diff --git a/apps/api/cmd/server/main.go b/apps/api/cmd/server/main.go index 3953c6d..08ba623 100644 --- a/apps/api/cmd/server/main.go +++ b/apps/api/cmd/server/main.go @@ -70,11 +70,15 @@ func main() { recurringHandler := handler.NewRecurringHandler(recurringSvc) accountRepo := repository.NewAccountRepository(pool) - accountSvc := service.NewAccountService(accountRepo) + cdiSvc := service.NewCDIYieldService(accountRepo, manualTxRepo) + creditBillRepo := repository.NewCreditBillRepository(pool) + creditBillSvc := service.NewCreditBillService(creditBillRepo, accountRepo) + accountSvc := service.NewAccountService(accountRepo, cdiSvc, creditBillSvc) accountHandler := handler.NewAccountHandler(accountSvc) + creditBillHandler := handler.NewCreditBillHandler(creditBillSvc) dashboardRepo := repository.NewDashboardRepository(pool) - dashboardSvc := service.NewDashboardService(dashboardRepo, recurringSvc, accountRepo) + dashboardSvc := service.NewDashboardService(dashboardRepo, recurringSvc, accountRepo, creditBillSvc) dashboardHandler := handler.NewDashboardHandler(dashboardSvc) r.Get("/health", handler.Health) @@ -105,11 +109,17 @@ 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) r.Put("/accounts/{id}", accountHandler.Update) r.Delete("/accounts/{id}", accountHandler.Delete) + r.Get("/accounts/{id}/bills", creditBillHandler.ListByAccount) + r.Get("/accounts/{id}/bills/current", creditBillHandler.GetCurrent) + r.Post("/accounts/{id}/bills/ensure", creditBillHandler.EnsureCurrent) + r.Post("/bills/{id}/pay", creditBillHandler.MarkPaid) r.Get("/dashboard", dashboardHandler.Get) diff --git a/apps/api/internal/handler/credit_bill.go b/apps/api/internal/handler/credit_bill.go new file mode 100644 index 0000000..d434b5d --- /dev/null +++ b/apps/api/internal/handler/credit_bill.go @@ -0,0 +1,86 @@ +package handler + +import ( + "encoding/json" + "net/http" + "strconv" + + "github.com/go-chi/chi/v5" + + "financeiro-carvalho/internal/service" +) + +type CreditBillHandler struct { + svc *service.CreditBillService +} + +func NewCreditBillHandler(svc *service.CreditBillService) *CreditBillHandler { + return &CreditBillHandler{svc: svc} +} + +func (h *CreditBillHandler) ListByAccount(w http.ResponseWriter, r *http.Request) { + id, err := strconv.Atoi(chi.URLParam(r, "id")) + if err != nil { + respondError(w, http.StatusBadRequest, "invalid id") + return + } + bills, err := h.svc.ListByAccount(r.Context(), id) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to list bills") + return + } + respondJSON(w, http.StatusOK, bills) +} + +func (h *CreditBillHandler) GetCurrent(w http.ResponseWriter, r *http.Request) { + id, err := strconv.Atoi(chi.URLParam(r, "id")) + if err != nil { + respondError(w, http.StatusBadRequest, "invalid id") + return + } + bill, err := h.svc.GetCurrent(r.Context(), id) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to get current bill") + return + } + if bill == nil { + respondJSON(w, http.StatusOK, nil) + return + } + respondJSON(w, http.StatusOK, bill) +} + +func (h *CreditBillHandler) EnsureCurrent(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 { + ClosingDay int `json:"closing_day"` + DueDay int `json:"due_day"` + } + _ = json.NewDecoder(r.Body).Decode(&body) + if err := h.svc.EnsureCurrentBill(r.Context(), id, body.ClosingDay, body.DueDay); err != nil { + respondError(w, http.StatusInternalServerError, "failed to ensure bill") + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (h *CreditBillHandler) MarkPaid(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 { + PaymentAccountID *int `json:"payment_account_id"` + } + _ = json.NewDecoder(r.Body).Decode(&body) + if err := h.svc.MarkPaid(r.Context(), id, body.PaymentAccountID); err != nil { + respondError(w, http.StatusInternalServerError, "failed to mark bill paid") + return + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/apps/api/internal/handler/recurring.go b/apps/api/internal/handler/recurring.go index 578bc1d..933da0a 100644 --- a/apps/api/internal/handler/recurring.go +++ b/apps/api/internal/handler/recurring.go @@ -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) +} diff --git a/apps/api/internal/migration/sql/009_recurring_income.sql b/apps/api/internal/migration/sql/009_recurring_income.sql new file mode 100644 index 0000000..790f9a9 --- /dev/null +++ b/apps/api/internal/migration/sql/009_recurring_income.sql @@ -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) +); diff --git a/apps/api/internal/migration/sql/010_cdi_yield.sql b/apps/api/internal/migration/sql/010_cdi_yield.sql new file mode 100644 index 0000000..e53da34 --- /dev/null +++ b/apps/api/internal/migration/sql/010_cdi_yield.sql @@ -0,0 +1,11 @@ +-- Add CDI yield support to accounts +ALTER TABLE accounts + ADD COLUMN IF NOT EXISTS yield_type VARCHAR(10) NOT NULL DEFAULT 'none' + CHECK (yield_type IN ('none', 'cdi', 'variable')), + ADD COLUMN IF NOT EXISTS last_yield_date DATE; + +-- Extend transactions.source check to allow 'yield' +ALTER TABLE transactions DROP CONSTRAINT IF EXISTS transactions_source_check; +ALTER TABLE transactions + ADD CONSTRAINT transactions_source_check + CHECK (source IN ('manual', 'import', 'yield')); diff --git a/apps/api/internal/migration/sql/011_credit_bills.sql b/apps/api/internal/migration/sql/011_credit_bills.sql new file mode 100644 index 0000000..2f5cc02 --- /dev/null +++ b/apps/api/internal/migration/sql/011_credit_bills.sql @@ -0,0 +1,19 @@ +-- Credit card billing period configuration per account +ALTER TABLE accounts + ADD COLUMN IF NOT EXISTS closing_day INTEGER CHECK (closing_day BETWEEN 1 AND 28), + ADD COLUMN IF NOT EXISTS due_day INTEGER CHECK (due_day BETWEEN 1 AND 28); + +-- Materialized bill metadata (calculated from transactions) +-- One row per (account, billing period start date) +CREATE TABLE IF NOT EXISTS credit_bills ( + id SERIAL PRIMARY KEY, + account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + period_start DATE NOT NULL, + period_end DATE NOT NULL, + due_date DATE NOT NULL, + paid BOOLEAN NOT NULL DEFAULT FALSE, + paid_at TIMESTAMP WITH TIME ZONE, + payment_account_id INTEGER REFERENCES accounts(id) ON DELETE SET NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + UNIQUE(account_id, period_start) +); diff --git a/apps/api/internal/model/account.go b/apps/api/internal/model/account.go index f3c7d52..9189011 100644 --- a/apps/api/internal/model/account.go +++ b/apps/api/internal/model/account.go @@ -6,6 +6,11 @@ type Account struct { Type string `json:"type"` InitialBalance float64 `json:"initial_balance"` Balance float64 `json:"balance"` + YieldType string `json:"yield_type"` // "none" | "cdi" | "variable" + LastYieldDate *string `json:"last_yield_date,omitempty"` + ClosingDay *int `json:"closing_day,omitempty"` + DueDay *int `json:"due_day,omitempty"` + CurrentBill *CreditBill `json:"current_bill,omitempty"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` } @@ -14,4 +19,7 @@ type AccountInput struct { Name string `json:"name"` Type string `json:"type"` InitialBalance float64 `json:"initial_balance"` + YieldType string `json:"yield_type"` // "none" | "cdi" | "variable" + ClosingDay *int `json:"closing_day,omitempty"` + DueDay *int `json:"due_day,omitempty"` } diff --git a/apps/api/internal/model/credit_bill.go b/apps/api/internal/model/credit_bill.go new file mode 100644 index 0000000..f644bfd --- /dev/null +++ b/apps/api/internal/model/credit_bill.go @@ -0,0 +1,22 @@ +package model + +type CreditBill struct { + ID int `json:"id"` + AccountID int `json:"account_id"` + AccountName string `json:"account_name"` + PeriodStart string `json:"period_start"` + PeriodEnd string `json:"period_end"` + DueDate string `json:"due_date"` + Total float64 `json:"total"` + Paid bool `json:"paid"` + PaidAt *string `json:"paid_at,omitempty"` + PaymentAccountID *int `json:"payment_account_id,omitempty"` +} + +type CreditBillInput struct { + AccountID int `json:"account_id"` + PeriodStart string `json:"period_start"` + PeriodEnd string `json:"period_end"` + DueDate string `json:"due_date"` + PaymentAccountID *int `json:"payment_account_id,omitempty"` +} diff --git a/apps/api/internal/model/dashboard.go b/apps/api/internal/model/dashboard.go index 889e712..429b097 100644 --- a/apps/api/internal/model/dashboard.go +++ b/apps/api/internal/model/dashboard.go @@ -24,13 +24,15 @@ 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"` + CurrentBills []CreditBill `json:"current_bills"` } diff --git a/apps/api/internal/model/recurring.go b/apps/api/internal/model/recurring.go index 2827ed2..37168bf 100644 --- a/apps/api/internal/model/recurring.go +++ b/apps/api/internal/model/recurring.go @@ -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"` +} diff --git a/apps/api/internal/repository/account.go b/apps/api/internal/repository/account.go index 4e3656a..deabce9 100644 --- a/apps/api/internal/repository/account.go +++ b/apps/api/internal/repository/account.go @@ -8,6 +8,17 @@ import ( "financeiro-carvalho/internal/model" ) +// AccountRepoWithYield extends the basic account repo with CDI-related operations. +type AccountRepoWithYield interface { + List(ctx context.Context) ([]model.Account, error) + GetByID(ctx context.Context, id int) (*model.Account, error) + Create(ctx context.Context, in model.AccountInput) (*model.Account, error) + Update(ctx context.Context, id int, in model.AccountInput) (*model.Account, error) + Delete(ctx context.Context, id int) error + TotalPatrimony(ctx context.Context) (float64, error) + UpdateLastYieldDate(ctx context.Context, id int, date string) error +} + type AccountRepository struct { pool *pgxpool.Pool } @@ -20,6 +31,8 @@ func (r *AccountRepository) List(ctx context.Context) ([]model.Account, error) { rows, err := r.pool.Query(ctx, ` SELECT a.id, a.name, a.type, a.initial_balance, + a.yield_type, a.last_yield_date::text, + a.closing_day, a.due_day, a.created_at::text, a.updated_at::text, a.initial_balance + COALESCE(SUM(CASE WHEN t.type = 'income' THEN t.amount ELSE 0 END), 0) @@ -38,7 +51,7 @@ func (r *AccountRepository) List(ctx context.Context) ([]model.Account, error) { var out []model.Account for rows.Next() { var a model.Account - if err := rows.Scan(&a.ID, &a.Name, &a.Type, &a.InitialBalance, &a.CreatedAt, &a.UpdatedAt, &a.Balance); err != nil { + if err := rows.Scan(&a.ID, &a.Name, &a.Type, &a.InitialBalance, &a.YieldType, &a.LastYieldDate, &a.ClosingDay, &a.DueDay, &a.CreatedAt, &a.UpdatedAt, &a.Balance); err != nil { return nil, err } out = append(out, a) @@ -51,6 +64,8 @@ func (r *AccountRepository) GetByID(ctx context.Context, id int) (*model.Account err := r.pool.QueryRow(ctx, ` SELECT a.id, a.name, a.type, a.initial_balance, + a.yield_type, a.last_yield_date::text, + a.closing_day, a.due_day, a.created_at::text, a.updated_at::text, a.initial_balance + COALESCE(SUM(CASE WHEN t.type = 'income' THEN t.amount ELSE 0 END), 0) @@ -60,7 +75,7 @@ func (r *AccountRepository) GetByID(ctx context.Context, id int) (*model.Account LEFT JOIN transactions t ON t.account_id = a.id WHERE a.id = $1 GROUP BY a.id - `, id).Scan(&a.ID, &a.Name, &a.Type, &a.InitialBalance, &a.CreatedAt, &a.UpdatedAt, &a.Balance) + `, id).Scan(&a.ID, &a.Name, &a.Type, &a.InitialBalance, &a.YieldType, &a.LastYieldDate, &a.ClosingDay, &a.DueDay, &a.CreatedAt, &a.UpdatedAt, &a.Balance) if err != nil { return nil, err } @@ -68,13 +83,17 @@ func (r *AccountRepository) GetByID(ctx context.Context, id int) (*model.Account } func (r *AccountRepository) Create(ctx context.Context, in model.AccountInput) (*model.Account, error) { + yt := in.YieldType + if yt == "" { + yt = "none" + } var a model.Account err := r.pool.QueryRow(ctx, ` - INSERT INTO accounts (name, type, initial_balance) - VALUES ($1, $2, $3) - RETURNING id, name, type, initial_balance, created_at::text, updated_at::text - `, in.Name, in.Type, in.InitialBalance). - Scan(&a.ID, &a.Name, &a.Type, &a.InitialBalance, &a.CreatedAt, &a.UpdatedAt) + INSERT INTO accounts (name, type, initial_balance, yield_type, closing_day, due_day) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id, name, type, initial_balance, yield_type, last_yield_date::text, closing_day, due_day, created_at::text, updated_at::text + `, in.Name, in.Type, in.InitialBalance, yt, in.ClosingDay, in.DueDay). + Scan(&a.ID, &a.Name, &a.Type, &a.InitialBalance, &a.YieldType, &a.LastYieldDate, &a.ClosingDay, &a.DueDay, &a.CreatedAt, &a.UpdatedAt) if err != nil { return nil, err } @@ -83,16 +102,19 @@ func (r *AccountRepository) Create(ctx context.Context, in model.AccountInput) ( } func (r *AccountRepository) Update(ctx context.Context, id int, in model.AccountInput) (*model.Account, error) { + yt := in.YieldType + if yt == "" { + yt = "none" + } row := r.pool.QueryRow(ctx, ` - UPDATE accounts SET name=$1, type=$2, initial_balance=$3, updated_at=NOW() - WHERE id=$4 - RETURNING id, name, type, initial_balance, created_at::text, updated_at::text - `, in.Name, in.Type, in.InitialBalance, id) + UPDATE accounts SET name=$1, type=$2, initial_balance=$3, yield_type=$4, closing_day=$5, due_day=$6, updated_at=NOW() + WHERE id=$7 + RETURNING id, name, type, initial_balance, yield_type, last_yield_date::text, closing_day, due_day, created_at::text, updated_at::text + `, in.Name, in.Type, in.InitialBalance, yt, in.ClosingDay, in.DueDay, id) var a model.Account - if err := row.Scan(&a.ID, &a.Name, &a.Type, &a.InitialBalance, &a.CreatedAt, &a.UpdatedAt); err != nil { + if err := row.Scan(&a.ID, &a.Name, &a.Type, &a.InitialBalance, &a.YieldType, &a.LastYieldDate, &a.ClosingDay, &a.DueDay, &a.CreatedAt, &a.UpdatedAt); err != nil { return nil, err } - // recalculate balance full, err := r.GetByID(ctx, a.ID) if err != nil { return nil, err @@ -119,3 +141,8 @@ func (r *AccountRepository) TotalPatrimony(ctx context.Context) (float64, error) `).Scan(&total) return total, err } + +func (r *AccountRepository) UpdateLastYieldDate(ctx context.Context, id int, date string) error { + _, err := r.pool.Exec(ctx, `UPDATE accounts SET last_yield_date = $1 WHERE id = $2`, date, id) + return err +} diff --git a/apps/api/internal/repository/credit_bill.go b/apps/api/internal/repository/credit_bill.go new file mode 100644 index 0000000..a0e1e79 --- /dev/null +++ b/apps/api/internal/repository/credit_bill.go @@ -0,0 +1,103 @@ +package repository + +import ( + "context" + "errors" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "financeiro-carvalho/internal/model" +) + +type CreditBillRepository interface { + ListByAccount(ctx context.Context, accountID int) ([]model.CreditBill, error) + GetCurrent(ctx context.Context, accountID int) (*model.CreditBill, error) + Upsert(ctx context.Context, in model.CreditBillInput) (*model.CreditBill, error) + MarkPaid(ctx context.Context, id int, paymentAccountID *int) error +} + +type creditBillRepo struct{ db *pgxpool.Pool } + +func NewCreditBillRepository(db *pgxpool.Pool) CreditBillRepository { + return &creditBillRepo{db: db} +} + +func (r *creditBillRepo) ListByAccount(ctx context.Context, accountID int) ([]model.CreditBill, error) { + rows, err := r.db.Query(ctx, ` + SELECT + cb.id, cb.account_id, a.name, + cb.period_start::text, cb.period_end::text, cb.due_date::text, + COALESCE(SUM(t.amount) FILTER (WHERE t.type = 'expense'), 0) AS total, + cb.paid, cb.paid_at::text, cb.payment_account_id + FROM credit_bills cb + JOIN accounts a ON a.id = cb.account_id + LEFT JOIN transactions t ON t.account_id = cb.account_id + AND t.date >= cb.period_start AND t.date <= cb.period_end + AND t.type = 'expense' + WHERE cb.account_id = $1 + GROUP BY cb.id, a.name + ORDER BY cb.period_start DESC + `, accountID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []model.CreditBill + for rows.Next() { + var b model.CreditBill + if err := rows.Scan(&b.ID, &b.AccountID, &b.AccountName, &b.PeriodStart, &b.PeriodEnd, &b.DueDate, &b.Total, &b.Paid, &b.PaidAt, &b.PaymentAccountID); err != nil { + return nil, err + } + out = append(out, b) + } + return out, rows.Err() +} + +func (r *creditBillRepo) GetCurrent(ctx context.Context, accountID int) (*model.CreditBill, error) { + var b model.CreditBill + err := r.db.QueryRow(ctx, ` + SELECT + cb.id, cb.account_id, a.name, + cb.period_start::text, cb.period_end::text, cb.due_date::text, + COALESCE(SUM(t.amount) FILTER (WHERE t.type = 'expense'), 0) AS total, + cb.paid, cb.paid_at::text, cb.payment_account_id + FROM credit_bills cb + JOIN accounts a ON a.id = cb.account_id + LEFT JOIN transactions t ON t.account_id = cb.account_id + AND t.date >= cb.period_start AND t.date <= cb.period_end + AND t.type = 'expense' + WHERE cb.account_id = $1 AND cb.paid = FALSE + GROUP BY cb.id, a.name + ORDER BY cb.period_start DESC + LIMIT 1 + `, accountID).Scan(&b.ID, &b.AccountID, &b.AccountName, &b.PeriodStart, &b.PeriodEnd, &b.DueDate, &b.Total, &b.Paid, &b.PaidAt, &b.PaymentAccountID) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + return &b, err +} + +func (r *creditBillRepo) Upsert(ctx context.Context, in model.CreditBillInput) (*model.CreditBill, error) { + var b model.CreditBill + err := r.db.QueryRow(ctx, ` + INSERT INTO credit_bills (account_id, period_start, period_end, due_date, payment_account_id) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (account_id, period_start) DO UPDATE + SET period_end = EXCLUDED.period_end, + due_date = EXCLUDED.due_date, + payment_account_id = EXCLUDED.payment_account_id + RETURNING id, account_id, period_start::text, period_end::text, due_date::text, paid, paid_at::text, payment_account_id + `, in.AccountID, in.PeriodStart, in.PeriodEnd, in.DueDate, in.PaymentAccountID). + Scan(&b.ID, &b.AccountID, &b.PeriodStart, &b.PeriodEnd, &b.DueDate, &b.Paid, &b.PaidAt, &b.PaymentAccountID) + return &b, err +} + +func (r *creditBillRepo) MarkPaid(ctx context.Context, id int, paymentAccountID *int) error { + _, err := r.db.Exec(ctx, ` + UPDATE credit_bills + SET paid = TRUE, paid_at = NOW(), payment_account_id = $2 + WHERE id = $1 + `, id, paymentAccountID) + return err +} diff --git a/apps/api/internal/repository/recurring.go b/apps/api/internal/repository/recurring.go index 590658a..9a39310 100644 --- a/apps/api/internal/repository/recurring.go +++ b/apps/api/internal/repository/recurring.go @@ -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 +} diff --git a/apps/api/internal/repository/transaction_manual.go b/apps/api/internal/repository/transaction_manual.go index 36d55aa..0f54150 100644 --- a/apps/api/internal/repository/transaction_manual.go +++ b/apps/api/internal/repository/transaction_manual.go @@ -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 } diff --git a/apps/api/internal/service/account.go b/apps/api/internal/service/account.go index 5172c58..031ffe2 100644 --- a/apps/api/internal/service/account.go +++ b/apps/api/internal/service/account.go @@ -5,6 +5,7 @@ import ( "errors" "financeiro-carvalho/internal/model" + "financeiro-carvalho/internal/repository" ) var ( @@ -26,11 +27,13 @@ type AccountRepo interface { } type AccountService struct { - repo AccountRepo + repo repository.AccountRepoWithYield + cdiSvc *CDIYieldService + billSvc *CreditBillService } -func NewAccountService(repo AccountRepo) *AccountService { - return &AccountService{repo: repo} +func NewAccountService(repo repository.AccountRepoWithYield, cdiSvc *CDIYieldService, billSvc *CreditBillService) *AccountService { + return &AccountService{repo: repo, cdiSvc: cdiSvc, billSvc: billSvc} } func (s *AccountService) validate(in model.AccountInput) error { @@ -43,8 +46,44 @@ func (s *AccountService) validate(in model.AccountInput) error { return nil } +// List returns accounts, applying CDI yield on-demand and attaching current bill for credit accounts. func (s *AccountService) List(ctx context.Context) ([]model.Account, error) { - return s.repo.List(ctx) + accounts, err := s.repo.List(ctx) + if err != nil { + return nil, err + } + + yieldApplied := false + for i := range accounts { + if accounts[i].YieldType == "cdi" { + _ = s.cdiSvc.ApplyYield(ctx, &accounts[i]) + yieldApplied = true + } + } + + if yieldApplied { + accounts, err = s.repo.List(ctx) + if err != nil { + return nil, err + } + } + + for i := range accounts { + if accounts[i].Type == "credit" { + closing, due := 1, 10 + if accounts[i].ClosingDay != nil { + closing = *accounts[i].ClosingDay + } + if accounts[i].DueDay != nil { + due = *accounts[i].DueDay + } + _ = s.billSvc.EnsureCurrentBill(ctx, accounts[i].ID, closing, due) + bill, _ := s.billSvc.GetCurrent(ctx, accounts[i].ID) + accounts[i].CurrentBill = bill + } + } + + return accounts, nil } func (s *AccountService) Create(ctx context.Context, in model.AccountInput) (*model.Account, error) { diff --git a/apps/api/internal/service/account_test.go b/apps/api/internal/service/account_test.go index 0d0eb72..a96f277 100644 --- a/apps/api/internal/service/account_test.go +++ b/apps/api/internal/service/account_test.go @@ -37,11 +37,32 @@ func (m *mockAccountRepo) Update(_ context.Context, id int, in model.AccountInpu } return nil, nil } -func (m *mockAccountRepo) Delete(_ context.Context, _ int) error { return nil } -func (m *mockAccountRepo) TotalPatrimony(_ context.Context) (float64, error) { return 0, nil } +func (m *mockAccountRepo) Delete(_ context.Context, _ int) error { return nil } +func (m *mockAccountRepo) TotalPatrimony(_ context.Context) (float64, error) { return 0, nil } +func (m *mockAccountRepo) UpdateLastYieldDate(_ context.Context, _ int, _ string) error { return nil } + +type mockCreditBillRepo struct{} + +func (m *mockCreditBillRepo) ListByAccount(_ context.Context, _ int) ([]model.CreditBill, error) { + return nil, nil +} +func (m *mockCreditBillRepo) GetCurrent(_ context.Context, _ int) (*model.CreditBill, error) { + return nil, nil +} +func (m *mockCreditBillRepo) Upsert(_ context.Context, _ model.CreditBillInput) (*model.CreditBill, error) { + return &model.CreditBill{}, nil +} +func (m *mockCreditBillRepo) MarkPaid(_ context.Context, _ int, _ *int) error { return nil } + +func newAccountSvc() *service.AccountService { + repo := &mockAccountRepo{} + cdiSvc := service.NewCDIYieldService(repo, &mockTxRepo{}) + billSvc := service.NewCreditBillService(&mockCreditBillRepo{}, repo) + return service.NewAccountService(repo, cdiSvc, billSvc) +} func TestCreateAccount_EmptyName(t *testing.T) { - svc := service.NewAccountService(&mockAccountRepo{}) + svc := newAccountSvc() _, err := svc.Create(context.Background(), model.AccountInput{Name: "", Type: "checking", InitialBalance: 0}) if err != service.ErrAccountEmptyName { t.Fatalf("expected ErrAccountEmptyName, got %v", err) @@ -49,7 +70,7 @@ func TestCreateAccount_EmptyName(t *testing.T) { } func TestCreateAccount_InvalidType(t *testing.T) { - svc := service.NewAccountService(&mockAccountRepo{}) + svc := newAccountSvc() _, err := svc.Create(context.Background(), model.AccountInput{Name: "Nubank", Type: "bitcoin"}) if err != service.ErrAccountInvalidType { t.Fatalf("expected ErrAccountInvalidType, got %v", err) @@ -57,7 +78,7 @@ func TestCreateAccount_InvalidType(t *testing.T) { } func TestCreateAccount_OK(t *testing.T) { - svc := service.NewAccountService(&mockAccountRepo{}) + svc := newAccountSvc() a, err := svc.Create(context.Background(), model.AccountInput{Name: "Nubank", Type: "checking", InitialBalance: 1000}) if err != nil { t.Fatal(err) diff --git a/apps/api/internal/service/cdi.go b/apps/api/internal/service/cdi.go new file mode 100644 index 0000000..72f640b --- /dev/null +++ b/apps/api/internal/service/cdi.go @@ -0,0 +1,139 @@ +package service + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + "financeiro-carvalho/internal/model" + "financeiro-carvalho/internal/repository" +) + +const bcbCDIURL = "https://api.bcb.gov.br/dados/serie/bcdata.sgs.12/dados?formato=json&dataInicial=%s&dataFinal=%s" + +type CDIYieldService struct { + accountRepo repository.AccountRepoWithYield + txRepo repository.ManualTransactionRepository + client *http.Client +} + +func NewCDIYieldService(accountRepo repository.AccountRepoWithYield, txRepo repository.ManualTransactionRepository) *CDIYieldService { + return &CDIYieldService{ + accountRepo: accountRepo, + txRepo: txRepo, + client: &http.Client{Timeout: 10 * time.Second}, + } +} + +// ApplyYield fetches CDI rates and creates a yield transaction for the account if applicable. +func (s *CDIYieldService) ApplyYield(ctx context.Context, a *model.Account) error { + if a.YieldType != "cdi" { + return nil + } + + // Determine the start date (day after last yield) + yesterday := time.Now().AddDate(0, 0, -1).Format("2006-01-02") + startDate := a.CreatedAt[:10] // default: account creation date + if a.LastYieldDate != nil && *a.LastYieldDate != "" { + startDate = *a.LastYieldDate + // start is exclusive: add one day + t, err := time.Parse("2006-01-02", startDate) + if err != nil { + return nil + } + startDate = t.AddDate(0, 0, 1).Format("2006-01-02") + } + + if startDate > yesterday { + // nothing to calculate yet + return nil + } + + rates, err := s.fetchCDIRates(startDate, yesterday) + if err != nil { + // BCB API unreachable — skip silently; will retry next load + return nil + } + if len(rates) == 0 { + return nil + } + + // Fill gaps (weekends/holidays) using last known rate + rateMap := make(map[string]float64, len(rates)) + for _, r := range rates { + rateMap[r.date] = r.value + } + + compound := 1.0 + lastRate := rates[0].value + start, _ := time.Parse("2006-01-02", startDate) + end, _ := time.Parse("2006-01-02", yesterday) + for d := start; !d.After(end); d = d.AddDate(0, 0, 1) { + key := d.Format("2006-01-02") + if r, ok := rateMap[key]; ok { + lastRate = r + } + compound *= 1 + lastRate/100 + } + + yieldAmount := a.Balance * (compound - 1) + if yieldAmount <= 0 { + return nil + } + + period := fmt.Sprintf("%s → %s", startDate, yesterday) + tx := model.Transaction{ + Date: time.Now().Format("2006-01-02"), + Amount: yieldAmount, + Description: fmt.Sprintf("Rendimento CDI — %s", period), + Type: "income", + Source: "yield", + AccountID: &a.ID, + } + if _, err := s.txRepo.Create(ctx, tx); err != nil { + return err + } + + return s.accountRepo.UpdateLastYieldDate(ctx, a.ID, yesterday) +} + +type bcbEntry struct { + date string + value float64 +} + +func (s *CDIYieldService) fetchCDIRates(startDate, endDate string) ([]bcbEntry, error) { + // BCB API uses DD/MM/YYYY format + start, _ := time.Parse("2006-01-02", startDate) + end, _ := time.Parse("2006-01-02", endDate) + url := fmt.Sprintf(bcbCDIURL, start.Format("02/01/2006"), end.Format("02/01/2006")) + + resp, err := s.client.Get(url) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var raw []struct { + Data string `json:"data"` + Valor string `json:"valor"` + } + if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil { + return nil, err + } + + var out []bcbEntry + for _, r := range raw { + // BCB date format: DD/MM/YYYY → convert to YYYY-MM-DD + t, err := time.Parse("02/01/2006", r.Data) + if err != nil { + continue + } + var v float64 + fmt.Sscanf(r.Valor, "%f", &v) + out = append(out, bcbEntry{date: t.Format("2006-01-02"), value: v}) + } + return out, nil +} diff --git a/apps/api/internal/service/credit_bill.go b/apps/api/internal/service/credit_bill.go new file mode 100644 index 0000000..085b5ba --- /dev/null +++ b/apps/api/internal/service/credit_bill.go @@ -0,0 +1,95 @@ +package service + +import ( + "context" + "errors" + "time" + + "financeiro-carvalho/internal/model" + "financeiro-carvalho/internal/repository" +) + +var ErrBillNotCreditAccount = errors.New("account is not a credit card account") + +type CreditBillService struct { + repo repository.CreditBillRepository + accountRepo repository.AccountRepoWithYield +} + +func NewCreditBillService(repo repository.CreditBillRepository, accountRepo repository.AccountRepoWithYield) *CreditBillService { + return &CreditBillService{repo: repo, accountRepo: accountRepo} +} + +func (s *CreditBillService) ListByAccount(ctx context.Context, accountID int) ([]model.CreditBill, error) { + bills, err := s.repo.ListByAccount(ctx, accountID) + if bills == nil { + return []model.CreditBill{}, err + } + return bills, err +} + +func (s *CreditBillService) GetCurrent(ctx context.Context, accountID int) (*model.CreditBill, error) { + return s.repo.GetCurrent(ctx, accountID) +} + +// EnsureCurrentBill creates (or refreshes) the current open bill for a credit account +// using the account's closing_day and due_day config (defaults: closing=1, due=10). +func (s *CreditBillService) EnsureCurrentBill(ctx context.Context, accountID int, closingDay, dueDay int) error { + if closingDay == 0 { + closingDay = 1 + } + if dueDay == 0 { + dueDay = 10 + } + + now := time.Now() + // Determine the current billing period based on closing day + var periodStart, periodEnd time.Time + if now.Day() > closingDay { + // We're after the closing day: current period is from closingDay+1 this month to closingDay next month + y, m, _ := now.Date() + periodStart = time.Date(y, m, closingDay+1, 0, 0, 0, 0, time.UTC) + next := time.Date(y, m+1, closingDay, 0, 0, 0, 0, time.UTC) + periodEnd = next + } else { + // We're before the closing day: current period is from closingDay+1 last month to closingDay this month + y, m, _ := now.Date() + periodStart = time.Date(y, m-1, closingDay+1, 0, 0, 0, 0, time.UTC) + periodEnd = time.Date(y, m, closingDay, 0, 0, 0, 0, time.UTC) + } + // Due date: dueDay of the month after periodEnd + dueDate := time.Date(periodEnd.Year(), periodEnd.Month()+1, dueDay, 0, 0, 0, 0, time.UTC) + + in := model.CreditBillInput{ + AccountID: accountID, + PeriodStart: periodStart.Format("2006-01-02"), + PeriodEnd: periodEnd.Format("2006-01-02"), + DueDate: dueDate.Format("2006-01-02"), + } + _, err := s.repo.Upsert(ctx, in) + return err +} + +func (s *CreditBillService) MarkPaid(ctx context.Context, id int, paymentAccountID *int) error { + return s.repo.MarkPaid(ctx, id, paymentAccountID) +} + +// currentBillsForDashboard returns unpaid current bills across all credit accounts. +func (s *CreditBillService) currentBillsForDashboard(ctx context.Context) ([]model.CreditBill, error) { + accounts, err := s.accountRepo.List(ctx) + if err != nil { + return nil, err + } + var bills []model.CreditBill + for _, a := range accounts { + if a.Type != "credit" { + continue + } + bill, err := s.repo.GetCurrent(ctx, a.ID) + if err != nil || bill == nil { + continue + } + bills = append(bills, *bill) + } + return bills, nil +} diff --git a/apps/api/internal/service/dashboard.go b/apps/api/internal/service/dashboard.go index 0028f45..10520fb 100644 --- a/apps/api/internal/service/dashboard.go +++ b/apps/api/internal/service/dashboard.go @@ -21,10 +21,11 @@ type DashboardService struct { repo DashboardRepo recurrSvc *RecurringService patrimony PatrimonySource + billSvc *CreditBillService } -func NewDashboardService(repo DashboardRepo, recurrSvc *RecurringService, patrimony PatrimonySource) *DashboardService { - return &DashboardService{repo: repo, recurrSvc: recurrSvc, patrimony: patrimony} +func NewDashboardService(repo DashboardRepo, recurrSvc *RecurringService, patrimony PatrimonySource, billSvc *CreditBillService) *DashboardService { + return &DashboardService{repo: repo, recurrSvc: recurrSvc, patrimony: patrimony, billSvc: billSvc} } func (s *DashboardService) Get(ctx context.Context, month string) (*model.DashboardData, error) { @@ -58,11 +59,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{} @@ -79,15 +96,22 @@ func (s *DashboardService) Get(ctx context.Context, month string) (*model.Dashbo return nil, err } + currentBills, _ := s.billSvc.currentBillsForDashboard(ctx) + if currentBills == nil { + currentBills = []model.CreditBill{} + } + 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, + CurrentBills: currentBills, }, nil } diff --git a/apps/api/internal/service/dashboard_test.go b/apps/api/internal/service/dashboard_test.go index 737719d..250566f 100644 --- a/apps/api/internal/service/dashboard_test.go +++ b/apps/api/internal/service/dashboard_test.go @@ -31,8 +31,10 @@ type mockPatrimony struct{} func (m *mockPatrimony) TotalPatrimony(_ context.Context) (float64, error) { return 0, nil } func newDashboardSvc(income, expenses float64) *service.DashboardService { + repo := &mockAccountRepo{} recurrSvc := service.NewRecurringService(newMockRecurring(nil), &mockTxRepo{}) - return service.NewDashboardService(&mockDashboardRepo{income: income, expenses: expenses}, recurrSvc, &mockPatrimony{}) + billSvc := service.NewCreditBillService(&mockCreditBillRepo{}, repo) + return service.NewDashboardService(&mockDashboardRepo{income: income, expenses: expenses}, recurrSvc, &mockPatrimony{}, billSvc) } func TestDashboard_SavingsPct_40(t *testing.T) { diff --git a/apps/api/internal/service/recurring.go b/apps/api/internal/service/recurring.go index 71d9932..93d0186 100644 --- a/apps/api/internal/service/recurring.go +++ b/apps/api/internal/service/recurring.go @@ -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) } diff --git a/apps/api/internal/service/recurring_test.go b/apps/api/internal/service/recurring_test.go index 5e0d814..c2009c1 100644 --- a/apps/api/internal/service/recurring_test.go +++ b/apps/api/internal/service/recurring_test.go @@ -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{}) diff --git a/apps/web/src/components/AppHud.vue b/apps/web/src/components/AppHud.vue index 2be94e0..6a27b7d 100644 --- a/apps/web/src/components/AppHud.vue +++ b/apps/web/src/components/AppHud.vue @@ -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.' }, ] diff --git a/apps/web/src/router/index.ts b/apps/web/src/router/index.ts index 169087f..c861736 100644 --- a/apps/web/src/router/index.ts +++ b/apps/web/src/router/index.ts @@ -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', }, ], }) diff --git a/apps/web/src/stores/accounts.ts b/apps/web/src/stores/accounts.ts index 95ae48b..bb9632d 100644 --- a/apps/web/src/stores/accounts.ts +++ b/apps/web/src/stores/accounts.ts @@ -2,12 +2,30 @@ import { defineStore } from 'pinia' import { ref } from 'vue' import { api } from '@/services/api' +export interface CreditBill { + id: number + account_id: number + account_name: string + period_start: string + period_end: string + due_date: string + total: number + paid: boolean + paid_at?: string + payment_account_id?: number +} + export interface Account { id: number name: string type: 'checking' | 'savings' | 'investment' | 'credit' initial_balance: number balance: number + yield_type: 'none' | 'cdi' | 'variable' + last_yield_date?: string + closing_day?: number + due_day?: number + current_bill?: CreditBill created_at: string updated_at: string } @@ -16,6 +34,9 @@ export interface AccountInput { name: string type: string initial_balance: number + yield_type: string + closing_day?: number | null + due_day?: number | null } export const useAccountsStore = defineStore('accounts', () => { @@ -53,5 +74,10 @@ export const useAccountsStore = defineStore('accounts', () => { accounts.value = accounts.value.filter((a) => a.id !== id) } - return { accounts, loading, error, fetchAll, create, update, remove } + async function payBill(billId: number, paymentAccountId?: number) { + await api.post(`/bills/${billId}/pay`, { payment_account_id: paymentAccountId ?? null }) + await fetchAll() + } + + return { accounts, loading, error, fetchAll, create, update, remove, payBill } }) diff --git a/apps/web/src/stores/dashboard.ts b/apps/web/src/stores/dashboard.ts index c6f8bbe..f0e562e 100644 --- a/apps/web/src/stores/dashboard.ts +++ b/apps/web/src/stores/dashboard.ts @@ -1,6 +1,7 @@ import { defineStore } from 'pinia' import { ref } from 'vue' import { api } from '@/services/api' +import type { CreditBill } from './accounts' export interface CategoryTotal { category_id: number | null @@ -25,6 +26,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 +44,8 @@ export interface DashboardData { monthly_evolution: MonthEvolution[] recent_transactions: RecentTransaction[] pending_recurring: number + pending_income_recurrings: PendingIncome[] + current_bills: CreditBill[] } export const useDashboardStore = defineStore('dashboard', () => { diff --git a/apps/web/src/stores/recurring.ts b/apps/web/src/stores/recurring.ts index 45cc722..48a1b80 100644 --- a/apps/web/src/stores/recurring.ts +++ b/apps/web/src/stores/recurring.ts @@ -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, + } }) diff --git a/apps/web/src/views/AccountsView.vue b/apps/web/src/views/AccountsView.vue index 0efff24..50e4dbb 100644 --- a/apps/web/src/views/AccountsView.vue +++ b/apps/web/src/views/AccountsView.vue @@ -13,7 +13,7 @@ const typeLabels: Record = { credit: 'Cartão de Crédito', } -const blank = (): AccountInput => ({ name: '', type: 'checking', initial_balance: 0 }) +const blank = (): AccountInput => ({ name: '', type: 'checking', initial_balance: 0, yield_type: 'none', closing_day: null, due_day: null }) const form = ref(blank()) const editId = ref(null) const initialRaw = ref('') @@ -27,11 +27,21 @@ function startEdit(id: number) { const a = store.accounts.find((x) => x.id === id) if (!a) return editId.value = id - form.value = { name: a.name, type: a.type, initial_balance: a.initial_balance } + form.value = { + name: a.name, type: a.type, initial_balance: a.initial_balance, + yield_type: a.yield_type ?? 'none', + closing_day: a.closing_day ?? null, + due_day: a.due_day ?? null, + } initialRaw.value = a.initial_balance.toLocaleString('pt-BR', { minimumFractionDigits: 2 }) formError.value = null } +async function payBill(billId: number) { + if (!confirm('Marcar fatura como PAGA?')) return + await store.payBill(billId) +} + function cancelEdit() { editId.value = null form.value = blank() @@ -85,6 +95,21 @@ function totalBalance() { + +
- {{ a.name }} +
+ {{ a.name }} + CDI + VAR +
{{ typeLabels[a.type] }} · inicial {{ fmt(a.initial_balance) }} @@ -138,6 +167,32 @@ function totalBalance() {
  • — nenhuma conta cadastrada —
  • + + +
    @@ -165,8 +220,9 @@ function totalBalance() { } .fc-acc-form__name { flex: 1; min-width: 160px; } -.fc-acc-form__type { width: 160px; } -.fc-acc-form__balance { width: 160px; } +.fc-acc-form__type { width: 150px; } +.fc-acc-form__yield { width: 160px; } +.fc-acc-form__balance { width: 150px; } .fc-acc-form__error { color: var(--fc-red); @@ -237,9 +293,27 @@ function totalBalance() { gap: 4px; } +.fc-acc-item__name-row { display: flex; align-items: center; gap: 8px; } .fc-acc-item__name { font-size: 14px; font-weight: 500; } .fc-acc-item__meta { font-size: 11px; color: var(--fc-text-dim); } +.fc-acc-badge { + font-size: 7px; padding: 2px 5px; border-radius: 2px; letter-spacing: .05em; +} +.fc-acc-badge--cdi { background: rgba(34,197,94,.2); color: var(--fc-green); border: 1px solid var(--fc-green); } +.fc-acc-badge--var { background: rgba(251,191,36,.2); color: var(--fc-gold); border: 1px solid var(--fc-gold); } + +.fc-acc-form__day-wrap { width: 90px; flex-shrink: 0; } +.fc-acc-form__day { width: 100%; } + +/* Credit bill widget */ +.fc-bill { display: flex; flex-direction: column; gap: var(--fc-space-3); } +.fc-bill__row { display: flex; justify-content: space-between; align-items: baseline; gap: var(--fc-space-2); } +.fc-bill__label { font-size: 10px; color: var(--fc-text-dim); } +.fc-bill__val { font-size: 12px; } +.fc-bill__actions { margin-top: var(--fc-space-2); } +.fc-bill__paid { font-size: 8px; color: var(--fc-green); margin-top: var(--fc-space-2); } + .fc-acc-item__right { display: flex; align-items: center; diff --git a/apps/web/src/views/HomeView.vue b/apps/web/src/views/HomeView.vue index c8e6de0..b21ff22 100644 --- a/apps/web/src/views/HomeView.vue +++ b/apps/web/src/views/HomeView.vue @@ -2,6 +2,8 @@ import { ref, computed, onMounted } from 'vue' import { useDashboardStore } from '@/stores/dashboard' import { useGameStore } from '@/stores/game' +import { useRecurringStore } from '@/stores/recurring' +import { useAccountsStore } from '@/stores/accounts' import NeonPanel from '@/components/NeonPanel.vue' import XPBar from '@/components/XPBar.vue' import CharacterSprite from '@/components/CharacterSprite.vue' @@ -9,6 +11,8 @@ import MonthSwitcher from '@/components/MonthSwitcher.vue' const dash = useDashboardStore() const game = useGameStore() +const recurring = useRecurringStore() +const accountsStore = useAccountsStore() const currentMonth = ref(new Date().toISOString().slice(0, 7)) onMounted(() => dash.fetch(currentMonth.value)) @@ -51,6 +55,29 @@ 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) + +const currentBills = computed(() => dash.data?.current_bills?.filter(b => !b.paid) ?? []) + +async function payBill(billId: number) { + await accountsStore.payBill(billId) + await dash.fetch(currentMonth.value) +} + +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) +}