feat: #44 fatura de cartão com data futura fica pendente
This commit is contained in:
@@ -65,7 +65,10 @@ func main() {
|
|||||||
|
|
||||||
transactionRepo := repository.NewTransactionRepository(pool)
|
transactionRepo := repository.NewTransactionRepository(pool)
|
||||||
importSvc := service.NewImportService(transactionRepo)
|
importSvc := service.NewImportService(transactionRepo)
|
||||||
importHandler := handler.NewImportHandler(importSvc, gameSvc)
|
pendingBillRepo := repository.NewPendingBillRepository(pool)
|
||||||
|
pendingBillSvc := service.NewPendingBillService(pendingBillRepo, transactionRepo)
|
||||||
|
importHandler := handler.NewImportHandler(importSvc, gameSvc, pendingBillSvc)
|
||||||
|
pendingBillHandler := handler.NewPendingBillHandler(pendingBillSvc, gameSvc)
|
||||||
|
|
||||||
manualTxRepo := repository.NewManualTransactionRepository(pool)
|
manualTxRepo := repository.NewManualTransactionRepository(pool)
|
||||||
txSvc := service.NewTransactionService(manualTxRepo)
|
txSvc := service.NewTransactionService(manualTxRepo)
|
||||||
@@ -84,7 +87,7 @@ func main() {
|
|||||||
creditBillHandler := handler.NewCreditBillHandler(creditBillSvc)
|
creditBillHandler := handler.NewCreditBillHandler(creditBillSvc)
|
||||||
|
|
||||||
dashboardRepo := repository.NewDashboardRepository(pool)
|
dashboardRepo := repository.NewDashboardRepository(pool)
|
||||||
dashboardSvc := service.NewDashboardService(dashboardRepo, recurringSvc, accountRepo, creditBillSvc)
|
dashboardSvc := service.NewDashboardService(dashboardRepo, recurringSvc, accountRepo, creditBillSvc, pendingBillSvc)
|
||||||
dashboardHandler := handler.NewDashboardHandler(dashboardSvc)
|
dashboardHandler := handler.NewDashboardHandler(dashboardSvc)
|
||||||
|
|
||||||
r.Get("/health", handler.Health)
|
r.Get("/health", handler.Health)
|
||||||
@@ -108,6 +111,10 @@ func main() {
|
|||||||
r.Post("/imports/preview", importHandler.Preview)
|
r.Post("/imports/preview", importHandler.Preview)
|
||||||
r.Post("/imports/confirm", importHandler.Confirm)
|
r.Post("/imports/confirm", importHandler.Confirm)
|
||||||
|
|
||||||
|
r.Get("/pending-bills", pendingBillHandler.List)
|
||||||
|
r.Post("/pending-bills/{id}/confirm", pendingBillHandler.Confirm)
|
||||||
|
r.Delete("/pending-bills/{id}", pendingBillHandler.Discard)
|
||||||
|
|
||||||
r.Get("/transactions", txHandler.List)
|
r.Get("/transactions", txHandler.List)
|
||||||
r.Post("/transactions", txHandler.Create)
|
r.Post("/transactions", txHandler.Create)
|
||||||
r.Put("/transactions/{id}", txHandler.Update)
|
r.Put("/transactions/{id}", txHandler.Update)
|
||||||
|
|||||||
@@ -13,10 +13,11 @@ const maxUploadSize = 10 << 20 // 10 MB
|
|||||||
type ImportHandler struct {
|
type ImportHandler struct {
|
||||||
svc *service.ImportService
|
svc *service.ImportService
|
||||||
gameSvc *service.GameService
|
gameSvc *service.GameService
|
||||||
|
pendingSvc *service.PendingBillService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewImportHandler(svc *service.ImportService, gameSvc *service.GameService) *ImportHandler {
|
func NewImportHandler(svc *service.ImportService, gameSvc *service.GameService, pendingSvc *service.PendingBillService) *ImportHandler {
|
||||||
return &ImportHandler{svc: svc, gameSvc: gameSvc}
|
return &ImportHandler{svc: svc, gameSvc: gameSvc, pendingSvc: pendingSvc}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Preview parses the uploaded file and returns rows with duplicate flags.
|
// Preview parses the uploaded file and returns rows with duplicate flags.
|
||||||
@@ -59,11 +60,14 @@ func (h *ImportHandler) Preview(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// Confirm saves the rows provided in the request body (after user review).
|
// Confirm saves the rows provided in the request body (after user review).
|
||||||
// POST /api/imports/confirm
|
// POST /api/imports/confirm
|
||||||
|
// When is_credit_card=true and payment_date is in the future, saves as pending instead of transactions.
|
||||||
func (h *ImportHandler) Confirm(w http.ResponseWriter, r *http.Request) {
|
func (h *ImportHandler) Confirm(w http.ResponseWriter, r *http.Request) {
|
||||||
var body struct {
|
var body struct {
|
||||||
Filename string `json:"filename"`
|
Filename string `json:"filename"`
|
||||||
Rows []model.ImportRow `json:"rows"`
|
Rows []model.ImportRow `json:"rows"`
|
||||||
ParseErrCount int `json:"parse_error_count"`
|
ParseErrCount int `json:"parse_error_count"`
|
||||||
|
IsCreditCard bool `json:"is_credit_card"`
|
||||||
|
PaymentDate string `json:"payment_date"` // YYYY-MM-DD
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
respondError(w, http.StatusBadRequest, "invalid JSON")
|
respondError(w, http.StatusBadRequest, "invalid JSON")
|
||||||
@@ -74,6 +78,24 @@ func (h *ImportHandler) Confirm(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if body.IsCreditCard && body.PaymentDate != "" {
|
||||||
|
saved, pending, err := h.pendingSvc.MaybeSaveAsPending(r.Context(), body.Filename, body.PaymentDate, body.Rows)
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, "failed to save pending bill")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if saved {
|
||||||
|
respondJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"pending": true,
|
||||||
|
"pending_bill": pending,
|
||||||
|
"imported": 0,
|
||||||
|
"duplicates": len(body.Rows) - len(pending.Rows),
|
||||||
|
"errors": body.ParseErrCount,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
result, err := h.svc.Confirm(r.Context(), body.Filename, body.Rows, body.ParseErrCount)
|
result, err := h.svc.Confirm(r.Context(), body.Filename, body.Rows, body.ParseErrCount)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respondError(w, http.StatusInternalServerError, "failed to save transactions")
|
respondError(w, http.StatusInternalServerError, "failed to save transactions")
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"financeiro-carvalho/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PendingBillHandler struct {
|
||||||
|
svc *service.PendingBillService
|
||||||
|
gameSvc *service.GameService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPendingBillHandler(svc *service.PendingBillService, gameSvc *service.GameService) *PendingBillHandler {
|
||||||
|
return &PendingBillHandler{svc: svc, gameSvc: gameSvc}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PendingBillHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||||
|
items, err := h.svc.List(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, "failed to list pending bills")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondJSON(w, http.StatusOK, items)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PendingBillHandler) Confirm(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := strconv.Atoi(chi.URLParam(r, "id"))
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusBadRequest, "invalid id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.svc.Confirm(r.Context(), id); err != nil {
|
||||||
|
if err == service.ErrPendingBillNotFound {
|
||||||
|
respondError(w, http.StatusNotFound, "pending bill not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondError(w, http.StatusInternalServerError, "failed to confirm pending bill")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.gameSvc.NotifyAction(r.Context(), "import_confirmed", map[string]any{"count": 1})
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PendingBillHandler) Discard(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := strconv.Atoi(chi.URLParam(r, "id"))
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusBadRequest, "invalid id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.svc.Discard(r.Context(), id); err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, "failed to discard pending bill")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
@@ -53,6 +53,9 @@ var m014 string
|
|||||||
//go:embed sql/015_accounts_cdi_percentage.sql
|
//go:embed sql/015_accounts_cdi_percentage.sql
|
||||||
var m015 string
|
var m015 string
|
||||||
|
|
||||||
|
//go:embed sql/016_pending_bill_imports.sql
|
||||||
|
var m016 string
|
||||||
|
|
||||||
func Run(ctx context.Context, pool *pgxpool.Pool) error {
|
func Run(ctx context.Context, pool *pgxpool.Pool) error {
|
||||||
// Bootstrap: ensure schema_migrations table exists before checking versions.
|
// Bootstrap: ensure schema_migrations table exists before checking versions.
|
||||||
if _, err := pool.Exec(ctx, `
|
if _, err := pool.Exec(ctx, `
|
||||||
@@ -64,7 +67,7 @@ func Run(ctx context.Context, pool *pgxpool.Pool) error {
|
|||||||
return fmt.Errorf("bootstrap schema_migrations: %w", err)
|
return fmt.Errorf("bootstrap schema_migrations: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
migrations := []string{m001, m002, m003, m004, m005, m006, m007, m008, m009, m010, m011, m012, m013, m014, m015}
|
migrations := []string{m001, m002, m003, m004, m005, m006, m007, m008, m009, m010, m011, m012, m013, m014, m015, m016}
|
||||||
for i, sql := range migrations {
|
for i, sql := range migrations {
|
||||||
version := i + 1
|
version := i + 1
|
||||||
var applied bool
|
var applied bool
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS pending_bill_imports (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
profile_id INTEGER NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||||
|
filename TEXT NOT NULL,
|
||||||
|
payment_date DATE NOT NULL,
|
||||||
|
total NUMERIC(12,2) NOT NULL DEFAULT 0,
|
||||||
|
rows JSONB NOT NULL DEFAULT '[]',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
@@ -35,4 +35,5 @@ type DashboardData struct {
|
|||||||
PendingRecurring int `json:"pending_recurring"`
|
PendingRecurring int `json:"pending_recurring"`
|
||||||
PendingIncomeRecurrings []PendingIncome `json:"pending_income_recurrings"`
|
PendingIncomeRecurrings []PendingIncome `json:"pending_income_recurrings"`
|
||||||
CurrentBills []CreditBill `json:"current_bills"`
|
CurrentBills []CreditBill `json:"current_bills"`
|
||||||
|
PendingBillImports []PendingBillImport `json:"pending_bill_imports"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
type PendingBillImport struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
PaymentDate string `json:"payment_date"`
|
||||||
|
Total float64 `json:"total"`
|
||||||
|
Rows []ImportRow `json:"rows"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"financeiro-carvalho/internal/middleware"
|
||||||
|
"financeiro-carvalho/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PendingBillRepository interface {
|
||||||
|
List(ctx context.Context) ([]model.PendingBillImport, error)
|
||||||
|
Create(ctx context.Context, filename, paymentDate string, total float64, rows []model.ImportRow) (*model.PendingBillImport, error)
|
||||||
|
GetByID(ctx context.Context, id int) (*model.PendingBillImport, error)
|
||||||
|
Delete(ctx context.Context, id int) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type pendingBillRepo struct{ pool *pgxpool.Pool }
|
||||||
|
|
||||||
|
func NewPendingBillRepository(pool *pgxpool.Pool) PendingBillRepository {
|
||||||
|
return &pendingBillRepo{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *pendingBillRepo) List(ctx context.Context) ([]model.PendingBillImport, error) {
|
||||||
|
pid := middleware.ProfileIDFromCtx(ctx)
|
||||||
|
rows, err := r.pool.Query(ctx, `
|
||||||
|
SELECT id, filename, payment_date::text, total, rows, created_at::text
|
||||||
|
FROM pending_bill_imports
|
||||||
|
WHERE profile_id = $1
|
||||||
|
ORDER BY payment_date
|
||||||
|
`, pid)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []model.PendingBillImport
|
||||||
|
for rows.Next() {
|
||||||
|
var p model.PendingBillImport
|
||||||
|
var rowsJSON []byte
|
||||||
|
if err := rows.Scan(&p.ID, &p.Filename, &p.PaymentDate, &p.Total, &rowsJSON, &p.CreatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(rowsJSON, &p.Rows)
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *pendingBillRepo) Create(ctx context.Context, filename, paymentDate string, total float64, rows []model.ImportRow) (*model.PendingBillImport, error) {
|
||||||
|
pid := middleware.ProfileIDFromCtx(ctx)
|
||||||
|
rowsJSON, err := json.Marshal(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var p model.PendingBillImport
|
||||||
|
var rowsBack []byte
|
||||||
|
err = r.pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO pending_bill_imports (profile_id, filename, payment_date, total, rows)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
RETURNING id, filename, payment_date::text, total, rows, created_at::text
|
||||||
|
`, pid, filename, paymentDate, total, rowsJSON).
|
||||||
|
Scan(&p.ID, &p.Filename, &p.PaymentDate, &p.Total, &rowsBack, &p.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(rowsBack, &p.Rows)
|
||||||
|
return &p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *pendingBillRepo) GetByID(ctx context.Context, id int) (*model.PendingBillImport, error) {
|
||||||
|
pid := middleware.ProfileIDFromCtx(ctx)
|
||||||
|
var p model.PendingBillImport
|
||||||
|
var rowsJSON []byte
|
||||||
|
err := r.pool.QueryRow(ctx, `
|
||||||
|
SELECT id, filename, payment_date::text, total, rows, created_at::text
|
||||||
|
FROM pending_bill_imports
|
||||||
|
WHERE id = $1 AND profile_id = $2
|
||||||
|
`, id, pid).Scan(&p.ID, &p.Filename, &p.PaymentDate, &p.Total, &rowsJSON, &p.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(rowsJSON, &p.Rows)
|
||||||
|
return &p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *pendingBillRepo) Delete(ctx context.Context, id int) error {
|
||||||
|
pid := middleware.ProfileIDFromCtx(ctx)
|
||||||
|
_, err := r.pool.Exec(ctx, `DELETE FROM pending_bill_imports WHERE id = $1 AND profile_id = $2`, id, pid)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -23,10 +23,11 @@ type DashboardService struct {
|
|||||||
recurrSvc *RecurringService
|
recurrSvc *RecurringService
|
||||||
patrimony PatrimonySource
|
patrimony PatrimonySource
|
||||||
billSvc *CreditBillService
|
billSvc *CreditBillService
|
||||||
|
pendingBillSvc *PendingBillService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewDashboardService(repo DashboardRepo, recurrSvc *RecurringService, patrimony PatrimonySource, billSvc *CreditBillService) *DashboardService {
|
func NewDashboardService(repo DashboardRepo, recurrSvc *RecurringService, patrimony PatrimonySource, billSvc *CreditBillService, pendingBillSvc *PendingBillService) *DashboardService {
|
||||||
return &DashboardService{repo: repo, recurrSvc: recurrSvc, patrimony: patrimony, billSvc: billSvc}
|
return &DashboardService{repo: repo, recurrSvc: recurrSvc, patrimony: patrimony, billSvc: billSvc, pendingBillSvc: pendingBillSvc}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *DashboardService) Get(ctx context.Context, month string) (*model.DashboardData, error) {
|
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{}
|
currentBills = []model.CreditBill{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pendingBillImports, _ := s.pendingBillSvc.List(ctx)
|
||||||
|
if pendingBillImports == nil {
|
||||||
|
pendingBillImports = []model.PendingBillImport{}
|
||||||
|
}
|
||||||
|
|
||||||
return &model.DashboardData{
|
return &model.DashboardData{
|
||||||
Month: month,
|
Month: month,
|
||||||
TotalIncome: income,
|
TotalIncome: income,
|
||||||
@@ -121,5 +127,6 @@ func (s *DashboardService) Get(ctx context.Context, month string) (*model.Dashbo
|
|||||||
PendingRecurring: pending,
|
PendingRecurring: pending,
|
||||||
PendingIncomeRecurrings: pendingIncome,
|
PendingIncomeRecurrings: pendingIncome,
|
||||||
CurrentBills: currentBills,
|
CurrentBills: currentBills,
|
||||||
|
PendingBillImports: pendingBillImports,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,21 @@ import (
|
|||||||
"financeiro-carvalho/internal/service"
|
"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 {
|
type mockDashboardRepo struct {
|
||||||
income float64
|
income float64
|
||||||
expenses float64
|
expenses float64
|
||||||
@@ -33,11 +48,25 @@ type mockPatrimony struct{}
|
|||||||
|
|
||||||
func (m *mockPatrimony) TotalPatrimony(_ context.Context) (float64, error) { return 0, nil }
|
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 {
|
func newDashboardSvc(income, expenses float64) *service.DashboardService {
|
||||||
repo := &mockAccountRepo{}
|
repo := &mockAccountRepo{}
|
||||||
recurrSvc := service.NewRecurringService(newMockRecurring(nil), &mockTxRepo{})
|
recurrSvc := service.NewRecurringService(newMockRecurring(nil), &mockTxRepo{})
|
||||||
billSvc := service.NewCreditBillService(&mockCreditBillRepo{}, repo)
|
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) {
|
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)
|
||||||
|
}
|
||||||
@@ -34,6 +34,14 @@ export interface PendingIncome {
|
|||||||
late: boolean
|
late: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PendingBillImport {
|
||||||
|
id: number
|
||||||
|
filename: string
|
||||||
|
payment_date: string
|
||||||
|
total: number
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface DashboardData {
|
export interface DashboardData {
|
||||||
month: string
|
month: string
|
||||||
total_income: number
|
total_income: number
|
||||||
@@ -46,6 +54,7 @@ export interface DashboardData {
|
|||||||
pending_recurring: number
|
pending_recurring: number
|
||||||
pending_income_recurrings: PendingIncome[]
|
pending_income_recurrings: PendingIncome[]
|
||||||
current_bills: CreditBill[]
|
current_bills: CreditBill[]
|
||||||
|
pending_bill_imports: PendingBillImport[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useDashboardStore = defineStore('dashboard', () => {
|
export const useDashboardStore = defineStore('dashboard', () => {
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ export interface ImportResult {
|
|||||||
imported: number
|
imported: number
|
||||||
duplicates: number
|
duplicates: number
|
||||||
errors: number
|
errors: number
|
||||||
|
pending?: boolean
|
||||||
|
pending_bill?: { id: number; payment_date: string; total: number }
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CSVMapping {
|
export interface CSVMapping {
|
||||||
@@ -63,7 +65,7 @@ export const useImportStore = defineStore('import', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function confirm() {
|
async function confirm(isCreditCard?: boolean, paymentDate?: string) {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
try {
|
try {
|
||||||
@@ -74,6 +76,8 @@ export const useImportStore = defineStore('import', () => {
|
|||||||
filename: filename.value,
|
filename: filename.value,
|
||||||
rows: rows.value,
|
rows: rows.value,
|
||||||
parse_error_count: parseErrors.value.length,
|
parse_error_count: parseErrors.value.length,
|
||||||
|
is_credit_card: isCreditCard ?? false,
|
||||||
|
payment_date: paymentDate ?? '',
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { api } from '@/services/api'
|
||||||
|
import type { PendingBillImport } from './dashboard'
|
||||||
|
|
||||||
|
export const usePendingBillsStore = defineStore('pending_bills', () => {
|
||||||
|
const items = ref<PendingBillImport[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
async function fetchAll() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
items.value = await api.get<PendingBillImport[]>('/pending-bills')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirm(id: number) {
|
||||||
|
await api.post(`/pending-bills/${id}/confirm`, {})
|
||||||
|
items.value = items.value.filter((p) => p.id !== id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function discard(id: number) {
|
||||||
|
await api.delete(`/pending-bills/${id}`)
|
||||||
|
items.value = items.value.filter((p) => p.id !== id)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { items, loading, fetchAll, confirm, discard }
|
||||||
|
})
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { useAccountsStore, type AccountInput } from '@/stores/accounts'
|
import { useAccountsStore, type AccountInput } from '@/stores/accounts'
|
||||||
|
import { usePendingBillsStore } from '@/stores/pending_bills'
|
||||||
import NeonPanel from '@/components/NeonPanel.vue'
|
import NeonPanel from '@/components/NeonPanel.vue'
|
||||||
import CurrencyInput from '@/components/CurrencyInput.vue'
|
import CurrencyInput from '@/components/CurrencyInput.vue'
|
||||||
|
|
||||||
const store = useAccountsStore()
|
const store = useAccountsStore()
|
||||||
onMounted(() => store.fetchAll())
|
const pendingStore = usePendingBillsStore()
|
||||||
|
onMounted(() => { store.fetchAll(); pendingStore.fetchAll() })
|
||||||
|
|
||||||
const typeLabels: Record<string, string> = {
|
const typeLabels: Record<string, string> = {
|
||||||
checking: 'Conta Corrente',
|
checking: 'Conta Corrente',
|
||||||
@@ -173,6 +175,34 @@ function totalBalance() {
|
|||||||
</ul>
|
</ul>
|
||||||
</NeonPanel>
|
</NeonPanel>
|
||||||
|
|
||||||
|
<!-- Pending bill imports -->
|
||||||
|
<template v-if="pendingStore.items.length > 0">
|
||||||
|
<NeonPanel title="FATURAS PENDENTES DE CONFIRMAÇÃO" variant="danger">
|
||||||
|
<div class="fc-pending-list">
|
||||||
|
<div
|
||||||
|
v-for="p in pendingStore.items"
|
||||||
|
:key="p.id"
|
||||||
|
class="fc-pending-item"
|
||||||
|
>
|
||||||
|
<div class="fc-pending-item__info">
|
||||||
|
<span class="fc-body fc-pending-item__name">{{ p.filename }}</span>
|
||||||
|
<span class="fc-mono fc-pending-item__meta">
|
||||||
|
Vencimento: {{ p.payment_date }} · {{ fmt(p.total) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="fc-pending-item__actions">
|
||||||
|
<button class="fc-btn fc-btn--sm fc-btn--primary" @click="pendingStore.confirm(p.id)">
|
||||||
|
CONFIRMAR PAGAMENTO
|
||||||
|
</button>
|
||||||
|
<button class="fc-btn fc-btn--sm fc-btn--ghost" @click="pendingStore.discard(p.id)">
|
||||||
|
DESCARTAR
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</NeonPanel>
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- Current bills for credit accounts -->
|
<!-- Current bills for credit accounts -->
|
||||||
<template v-for="a in store.accounts.filter(x => x.type === 'credit' && x.current_bill)" :key="'bill-' + a.id">
|
<template v-for="a in store.accounts.filter(x => x.type === 'credit' && x.current_bill)" :key="'bill-' + a.id">
|
||||||
<NeonPanel :title="`FATURA · ${a.name}`" :variant="a.current_bill!.paid ? undefined : 'danger'">
|
<NeonPanel :title="`FATURA · ${a.name}`" :variant="a.current_bill!.paid ? undefined : 'danger'">
|
||||||
@@ -327,4 +357,21 @@ function totalBalance() {
|
|||||||
|
|
||||||
.fc-acc-item__balance { font-size: 16px; font-weight: 700; }
|
.fc-acc-item__balance { font-size: 16px; font-weight: 700; }
|
||||||
.fc-acc-item__actions { display: flex; gap: var(--fc-space-1); }
|
.fc-acc-item__actions { display: flex; gap: var(--fc-space-1); }
|
||||||
|
|
||||||
|
/* Pending bill imports */
|
||||||
|
.fc-pending-list { display: flex; flex-direction: column; gap: var(--fc-space-3); }
|
||||||
|
.fc-pending-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--fc-space-3);
|
||||||
|
padding: 10px var(--fc-space-3);
|
||||||
|
border: 1px solid rgba(255,59,107,.3);
|
||||||
|
border-radius: var(--fc-radius);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.fc-pending-item__info { display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.fc-pending-item__name { font-size: 13px; }
|
||||||
|
.fc-pending-item__meta { font-size: 11px; color: var(--fc-text-dim); }
|
||||||
|
.fc-pending-item__actions { display: flex; gap: var(--fc-space-2); }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -76,8 +76,26 @@ function fmt(amount: number) {
|
|||||||
<div class="fc-view">
|
<div class="fc-view">
|
||||||
<span class="fc-pixel fc-view__title">:: IMPORTAR EXTRATO</span>
|
<span class="fc-pixel fc-view__title">:: IMPORTAR EXTRATO</span>
|
||||||
|
|
||||||
<!-- Result banner -->
|
<!-- Result banner: pending bill -->
|
||||||
<NeonPanel v-if="store.result" variant="success" title="CONCLUÍDO">
|
<NeonPanel v-if="store.result?.pending" variant="success" title="FATURA SALVA COMO PENDENTE">
|
||||||
|
<div class="fc-import-result">
|
||||||
|
<span class="fc-mono fc-import-result__stat">
|
||||||
|
Vencimento: <span class="fc-text-gold">{{ store.result.pending_bill?.payment_date }}</span>
|
||||||
|
</span>
|
||||||
|
<span class="fc-mono fc-import-result__stat fc-text-green">
|
||||||
|
{{ fmt(store.result.pending_bill?.total ?? 0) }}
|
||||||
|
</span>
|
||||||
|
<span class="fc-mono fc-import-result__stat" style="color:var(--fc-text-dim);font-size:11px">
|
||||||
|
Confirme o pagamento em Contas quando pagar a fatura
|
||||||
|
</span>
|
||||||
|
<button class="fc-btn fc-btn--ghost" @click="store.reset(); selectedFile = null">
|
||||||
|
IMPORTAR OUTRO
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</NeonPanel>
|
||||||
|
|
||||||
|
<!-- Result banner: imported -->
|
||||||
|
<NeonPanel v-else-if="store.result" variant="success" title="CONCLUÍDO">
|
||||||
<div class="fc-import-result">
|
<div class="fc-import-result">
|
||||||
<span class="fc-mono fc-import-result__stat">
|
<span class="fc-mono fc-import-result__stat">
|
||||||
<span class="fc-text-green">{{ store.result.imported }}</span> importadas
|
<span class="fc-text-green">{{ store.result.imported }}</span> importadas
|
||||||
@@ -243,7 +261,7 @@ function fmt(amount: number) {
|
|||||||
<button
|
<button
|
||||||
class="fc-btn fc-btn--primary"
|
class="fc-btn fc-btn--primary"
|
||||||
:disabled="store.loading || newCount === 0"
|
:disabled="store.loading || newCount === 0"
|
||||||
@click="store.confirm()"
|
@click="store.confirm(csvMapping.all_expenses, csvMapping.all_expenses ? csvMapping.payment_date : '')"
|
||||||
>
|
>
|
||||||
{{ store.loading ? 'SALVANDO...' : `CONFIRMAR ${newCount} TRANSAÇÕES` }}
|
{{ store.loading ? 'SALVANDO...' : `CONFIRMAR ${newCount} TRANSAÇÕES` }}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user