diff --git a/apps/api/cmd/server/main.go b/apps/api/cmd/server/main.go index fde1d4b..08ba623 100644 --- a/apps/api/cmd/server/main.go +++ b/apps/api/cmd/server/main.go @@ -71,11 +71,14 @@ func main() { accountRepo := repository.NewAccountRepository(pool) cdiSvc := service.NewCDIYieldService(accountRepo, manualTxRepo) - accountSvc := service.NewAccountService(accountRepo, cdiSvc) + 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) @@ -113,6 +116,10 @@ func main() { 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/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 de81560..9189011 100644 --- a/apps/api/internal/model/account.go +++ b/apps/api/internal/model/account.go @@ -8,6 +8,9 @@ type Account struct { 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"` } @@ -17,4 +20,6 @@ type AccountInput struct { 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 a0fbd93..429b097 100644 --- a/apps/api/internal/model/dashboard.go +++ b/apps/api/internal/model/dashboard.go @@ -34,4 +34,5 @@ type DashboardData struct { 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/repository/account.go b/apps/api/internal/repository/account.go index 1dfe227..deabce9 100644 --- a/apps/api/internal/repository/account.go +++ b/apps/api/internal/repository/account.go @@ -32,6 +32,7 @@ func (r *AccountRepository) List(ctx context.Context) ([]model.Account, error) { 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) @@ -50,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.YieldType, &a.LastYieldDate, &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) @@ -64,6 +65,7 @@ func (r *AccountRepository) GetByID(ctx context.Context, id int) (*model.Account 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) @@ -73,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.YieldType, &a.LastYieldDate, &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 } @@ -87,11 +89,11 @@ func (r *AccountRepository) Create(ctx context.Context, in model.AccountInput) ( } var a model.Account err := r.pool.QueryRow(ctx, ` - INSERT INTO accounts (name, type, initial_balance, yield_type) - VALUES ($1, $2, $3, $4) - RETURNING id, name, type, initial_balance, yield_type, last_yield_date::text, created_at::text, updated_at::text - `, in.Name, in.Type, in.InitialBalance, yt). - Scan(&a.ID, &a.Name, &a.Type, &a.InitialBalance, &a.YieldType, &a.LastYieldDate, &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 } @@ -105,12 +107,12 @@ func (r *AccountRepository) Update(ctx context.Context, id int, in model.Account yt = "none" } row := r.pool.QueryRow(ctx, ` - UPDATE accounts SET name=$1, type=$2, initial_balance=$3, yield_type=$4, updated_at=NOW() - WHERE id=$5 - RETURNING id, name, type, initial_balance, yield_type, last_yield_date::text, created_at::text, updated_at::text - `, in.Name, in.Type, in.InitialBalance, yt, 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.YieldType, &a.LastYieldDate, &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 } full, err := r.GetByID(ctx, a.ID) 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/service/account.go b/apps/api/internal/service/account.go index d7a613f..031ffe2 100644 --- a/apps/api/internal/service/account.go +++ b/apps/api/internal/service/account.go @@ -27,12 +27,13 @@ type AccountRepo interface { } type AccountService struct { - repo repository.AccountRepoWithYield - cdiSvc *CDIYieldService + repo repository.AccountRepoWithYield + cdiSvc *CDIYieldService + billSvc *CreditBillService } -func NewAccountService(repo repository.AccountRepoWithYield, cdiSvc *CDIYieldService) *AccountService { - return &AccountService{repo: repo, cdiSvc: cdiSvc} +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 { @@ -45,22 +46,44 @@ func (s *AccountService) validate(in model.AccountInput) error { return nil } -// List returns accounts, applying CDI yield on-demand for cdi-type accounts. +// 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) { accounts, err := s.repo.List(ctx) if err != nil { return nil, err } + yieldApplied := false for i := range accounts { if accounts[i].YieldType == "cdi" { - // Apply CDI yield — errors are non-fatal (BCB API may be unavailable) _ = s.cdiSvc.ApplyYield(ctx, &accounts[i]) + yieldApplied = true } } - // Re-fetch after yield transactions may have been created to get updated balances - return s.repo.List(ctx) + 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 09e8fa9..a96f277 100644 --- a/apps/api/internal/service/account_test.go +++ b/apps/api/internal/service/account_test.go @@ -41,10 +41,24 @@ func (m *mockAccountRepo) Delete(_ context.Context, _ int) error 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{}) - return service.NewAccountService(repo, cdiSvc) + billSvc := service.NewCreditBillService(&mockCreditBillRepo{}, repo) + return service.NewAccountService(repo, cdiSvc, billSvc) } func TestCreateAccount_EmptyName(t *testing.T) { 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 41e578f..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) { @@ -95,6 +96,11 @@ 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, @@ -106,5 +112,6 @@ func (s *DashboardService) Get(ctx context.Context, month string) (*model.Dashbo 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/web/src/stores/accounts.ts b/apps/web/src/stores/accounts.ts index 7225602..bb9632d 100644 --- a/apps/web/src/stores/accounts.ts +++ b/apps/web/src/stores/accounts.ts @@ -2,6 +2,19 @@ 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 @@ -10,6 +23,9 @@ export interface Account { 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 } @@ -19,6 +35,8 @@ export interface AccountInput { type: string initial_balance: number yield_type: string + closing_day?: number | null + due_day?: number | null } export const useAccountsStore = defineStore('accounts', () => { @@ -56,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 97a11c4..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 @@ -44,6 +45,7 @@ export interface DashboardData { recent_transactions: RecentTransaction[] pending_recurring: number pending_income_recurrings: PendingIncome[] + current_bills: CreditBill[] } export const useDashboardStore = defineStore('dashboard', () => { diff --git a/apps/web/src/views/AccountsView.vue b/apps/web/src/views/AccountsView.vue index 737968b..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, yield_type: 'none' }) +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, yield_type: a.yield_type ?? 'none' } + 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() @@ -90,6 +100,16 @@ function totalBalance() { + — nenhuma conta cadastrada — + + + @@ -257,6 +303,17 @@ function totalBalance() { .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 a2a882f..b21ff22 100644 --- a/apps/web/src/views/HomeView.vue +++ b/apps/web/src/views/HomeView.vue @@ -3,6 +3,7 @@ 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' @@ -11,6 +12,7 @@ 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)) @@ -61,6 +63,13 @@ 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) @@ -249,6 +258,21 @@ async function markLate(id: number) { + + +
+
+
+ {{ bill.account_name }} + vence {{ bill.due_date }} +
+
+ {{ fmt(bill.total) }} + +
+
+
+
@@ -338,6 +362,15 @@ async function markLate(id: number) { .empty { font-size: 8px; color: var(--fc-text-dim); padding: 8px 0; } +/* Bill widget */ +.bill-list { display: flex; flex-direction: column; gap: 10px; } +.bill-item { display: flex; justify-content: space-between; align-items: center; gap: var(--fc-space-2); flex-wrap: wrap; } +.bill-item__info { display: flex; flex-direction: column; gap: 3px; } +.bill-item__name { font-size: 13px; font-weight: 500; } +.bill-item__due { font-size: 10px; } +.bill-item__right { display: flex; align-items: center; gap: var(--fc-space-2); } +.bill-item__total { font-size: 15px; } + /* Income widget */ .dash-income-panel { margin-bottom: 0; } .dash-alert--salary { background: rgba(255,180,0,.08); border-color: var(--fc-gold); }