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