feat(#37): módulo de cartão de crédito com faturas mensais

- Migration 011: closing_day/due_day em accounts + tabela credit_bills
- CreditBillService: cria/atualiza fatura corrente on-demand no GET /accounts
- Widget FATURA ATUAL no dashboard com total e botão PAGAR
- AccountsView: campos closing_day/due_day para contas credit + painel fatura
- Dashboard: current_bills lista faturas não pagas de todos os cartões

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
2026-05-27 15:12:53 -03:00
co-authored by Claude Sonnet 4.6
parent ee6cd9f37e
commit e3b3433703
17 changed files with 530 additions and 29 deletions
+31 -8
View File
@@ -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) {
+15 -1
View File
@@ -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) {
+95
View File
@@ -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
}
+9 -2
View File
@@ -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
}
+3 -1
View File
@@ -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) {