feat: #44 fatura de cartão com data futura fica pendente até confirmação
- Nova tabela pending_bill_imports (migration 016) - ImportHandler.Confirm: quando is_credit_card=true e payment_date > hoje, salva como pending em vez de inserir transações - Novos endpoints: GET /pending-bills, POST /pending-bills/:id/confirm, DELETE /pending-bills/:id - Dashboard inclui pending_bill_imports no payload - Frontend: resultado "fatura salva como pendente" no ImportView - AccountsView exibe widget de faturas pendentes com ações de confirmar/descartar - dashboard_test: mock de PendingBillRepo + TransactionRepository Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
@@ -19,14 +19,15 @@ type PatrimonySource interface {
|
||||
}
|
||||
|
||||
type DashboardService struct {
|
||||
repo DashboardRepo
|
||||
recurrSvc *RecurringService
|
||||
patrimony PatrimonySource
|
||||
billSvc *CreditBillService
|
||||
repo DashboardRepo
|
||||
recurrSvc *RecurringService
|
||||
patrimony PatrimonySource
|
||||
billSvc *CreditBillService
|
||||
pendingBillSvc *PendingBillService
|
||||
}
|
||||
|
||||
func NewDashboardService(repo DashboardRepo, recurrSvc *RecurringService, patrimony PatrimonySource, billSvc *CreditBillService) *DashboardService {
|
||||
return &DashboardService{repo: repo, recurrSvc: recurrSvc, patrimony: patrimony, billSvc: billSvc}
|
||||
func NewDashboardService(repo DashboardRepo, recurrSvc *RecurringService, patrimony PatrimonySource, billSvc *CreditBillService, pendingBillSvc *PendingBillService) *DashboardService {
|
||||
return &DashboardService{repo: repo, recurrSvc: recurrSvc, patrimony: patrimony, billSvc: billSvc, pendingBillSvc: pendingBillSvc}
|
||||
}
|
||||
|
||||
func (s *DashboardService) Get(ctx context.Context, month string) (*model.DashboardData, error) {
|
||||
@@ -109,6 +110,11 @@ func (s *DashboardService) Get(ctx context.Context, month string) (*model.Dashbo
|
||||
currentBills = []model.CreditBill{}
|
||||
}
|
||||
|
||||
pendingBillImports, _ := s.pendingBillSvc.List(ctx)
|
||||
if pendingBillImports == nil {
|
||||
pendingBillImports = []model.PendingBillImport{}
|
||||
}
|
||||
|
||||
return &model.DashboardData{
|
||||
Month: month,
|
||||
TotalIncome: income,
|
||||
@@ -121,5 +127,6 @@ func (s *DashboardService) Get(ctx context.Context, month string) (*model.Dashbo
|
||||
PendingRecurring: pending,
|
||||
PendingIncomeRecurrings: pendingIncome,
|
||||
CurrentBills: currentBills,
|
||||
PendingBillImports: pendingBillImports,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -8,6 +8,21 @@ import (
|
||||
"financeiro-carvalho/internal/service"
|
||||
)
|
||||
|
||||
type mockImportTxRepo struct{}
|
||||
|
||||
func (m *mockImportTxRepo) IsDuplicate(_ context.Context, _, _ string, _ float64) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
func (m *mockImportTxRepo) IsExternalIDKnown(_ context.Context, _ string) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
func (m *mockImportTxRepo) BulkInsert(_ context.Context, rows []model.ImportRow) (int, error) {
|
||||
return len(rows), nil
|
||||
}
|
||||
func (m *mockImportTxRepo) SaveImportLog(_ context.Context, _, _ string, _, _, _ int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type mockDashboardRepo struct {
|
||||
income float64
|
||||
expenses float64
|
||||
@@ -33,11 +48,25 @@ type mockPatrimony struct{}
|
||||
|
||||
func (m *mockPatrimony) TotalPatrimony(_ context.Context) (float64, error) { return 0, nil }
|
||||
|
||||
type mockPendingBillRepo struct{}
|
||||
|
||||
func (m *mockPendingBillRepo) List(_ context.Context) ([]model.PendingBillImport, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (m *mockPendingBillRepo) Create(_ context.Context, _, _ string, _ float64, _ []model.ImportRow) (*model.PendingBillImport, error) {
|
||||
return &model.PendingBillImport{}, nil
|
||||
}
|
||||
func (m *mockPendingBillRepo) GetByID(_ context.Context, _ int) (*model.PendingBillImport, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (m *mockPendingBillRepo) Delete(_ context.Context, _ int) error { return nil }
|
||||
|
||||
func newDashboardSvc(income, expenses float64) *service.DashboardService {
|
||||
repo := &mockAccountRepo{}
|
||||
recurrSvc := service.NewRecurringService(newMockRecurring(nil), &mockTxRepo{})
|
||||
billSvc := service.NewCreditBillService(&mockCreditBillRepo{}, repo)
|
||||
return service.NewDashboardService(&mockDashboardRepo{income: income, expenses: expenses}, recurrSvc, &mockPatrimony{}, billSvc)
|
||||
pendingBillSvc := service.NewPendingBillService(&mockPendingBillRepo{}, &mockImportTxRepo{})
|
||||
return service.NewDashboardService(&mockDashboardRepo{income: income, expenses: expenses}, recurrSvc, &mockPatrimony{}, billSvc, pendingBillSvc)
|
||||
}
|
||||
|
||||
func TestDashboard_SavingsPct_40(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"financeiro-carvalho/internal/model"
|
||||
"financeiro-carvalho/internal/repository"
|
||||
)
|
||||
|
||||
var ErrPendingBillNotFound = errors.New("pending bill not found")
|
||||
|
||||
type PendingBillService struct {
|
||||
repo repository.PendingBillRepository
|
||||
txRepo repository.TransactionRepository
|
||||
}
|
||||
|
||||
func NewPendingBillService(repo repository.PendingBillRepository, txRepo repository.TransactionRepository) *PendingBillService {
|
||||
return &PendingBillService{repo: repo, txRepo: txRepo}
|
||||
}
|
||||
|
||||
func (s *PendingBillService) List(ctx context.Context) ([]model.PendingBillImport, error) {
|
||||
items, err := s.repo.List(ctx)
|
||||
if items == nil {
|
||||
return []model.PendingBillImport{}, err
|
||||
}
|
||||
return items, err
|
||||
}
|
||||
|
||||
// Save stores a credit-card import as pending when payment_date is in the future.
|
||||
// Returns (true, pendingBill, nil) if saved as pending; (false, nil, nil) if not applicable.
|
||||
func (s *PendingBillService) MaybeSaveAsPending(ctx context.Context, filename, paymentDate string, rows []model.ImportRow) (bool, *model.PendingBillImport, error) {
|
||||
if paymentDate == "" {
|
||||
return false, nil, nil
|
||||
}
|
||||
t, err := time.Parse("2006-01-02", paymentDate)
|
||||
if err != nil || !t.After(time.Now().Truncate(24*time.Hour)) {
|
||||
return false, nil, nil
|
||||
}
|
||||
|
||||
var total float64
|
||||
newRows := make([]model.ImportRow, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
if !r.IsDuplicate {
|
||||
total += r.Amount
|
||||
newRows = append(newRows, r)
|
||||
}
|
||||
}
|
||||
|
||||
p, err := s.repo.Create(ctx, filename, paymentDate, total, newRows)
|
||||
if err != nil {
|
||||
return true, nil, err
|
||||
}
|
||||
return true, p, nil
|
||||
}
|
||||
|
||||
// Confirm inserts the pending bill's rows as actual transactions and removes the pending record.
|
||||
func (s *PendingBillService) Confirm(ctx context.Context, id int) error {
|
||||
p, err := s.repo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return ErrPendingBillNotFound
|
||||
}
|
||||
if _, err := s.txRepo.BulkInsert(ctx, p.Rows); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.repo.Delete(ctx, id)
|
||||
}
|
||||
|
||||
// Discard removes a pending bill import without creating transactions.
|
||||
func (s *PendingBillService) Discard(ctx context.Context, id int) error {
|
||||
return s.repo.Delete(ctx, id)
|
||||
}
|
||||
Reference in New Issue
Block a user