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)
|
||||
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)
|
||||
txSvc := service.NewTransactionService(manualTxRepo)
|
||||
@@ -84,7 +87,7 @@ func main() {
|
||||
creditBillHandler := handler.NewCreditBillHandler(creditBillSvc)
|
||||
|
||||
dashboardRepo := repository.NewDashboardRepository(pool)
|
||||
dashboardSvc := service.NewDashboardService(dashboardRepo, recurringSvc, accountRepo, creditBillSvc)
|
||||
dashboardSvc := service.NewDashboardService(dashboardRepo, recurringSvc, accountRepo, creditBillSvc, pendingBillSvc)
|
||||
dashboardHandler := handler.NewDashboardHandler(dashboardSvc)
|
||||
|
||||
r.Get("/health", handler.Health)
|
||||
@@ -108,6 +111,10 @@ func main() {
|
||||
r.Post("/imports/preview", importHandler.Preview)
|
||||
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.Post("/transactions", txHandler.Create)
|
||||
r.Put("/transactions/{id}", txHandler.Update)
|
||||
|
||||
@@ -13,10 +13,11 @@ const maxUploadSize = 10 << 20 // 10 MB
|
||||
type ImportHandler struct {
|
||||
svc *service.ImportService
|
||||
gameSvc *service.GameService
|
||||
pendingSvc *service.PendingBillService
|
||||
}
|
||||
|
||||
func NewImportHandler(svc *service.ImportService, gameSvc *service.GameService) *ImportHandler {
|
||||
return &ImportHandler{svc: svc, gameSvc: gameSvc}
|
||||
func NewImportHandler(svc *service.ImportService, gameSvc *service.GameService, pendingSvc *service.PendingBillService) *ImportHandler {
|
||||
return &ImportHandler{svc: svc, gameSvc: gameSvc, pendingSvc: pendingSvc}
|
||||
}
|
||||
|
||||
// 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).
|
||||
// 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) {
|
||||
var body struct {
|
||||
Filename string `json:"filename"`
|
||||
Rows []model.ImportRow `json:"rows"`
|
||||
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 {
|
||||
respondError(w, http.StatusBadRequest, "invalid JSON")
|
||||
@@ -74,6 +78,24 @@ func (h *ImportHandler) Confirm(w http.ResponseWriter, r *http.Request) {
|
||||
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)
|
||||
if err != nil {
|
||||
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
|
||||
var m015 string
|
||||
|
||||
//go:embed sql/016_pending_bill_imports.sql
|
||||
var m016 string
|
||||
|
||||
func Run(ctx context.Context, pool *pgxpool.Pool) error {
|
||||
// Bootstrap: ensure schema_migrations table exists before checking versions.
|
||||
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)
|
||||
}
|
||||
|
||||
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 {
|
||||
version := i + 1
|
||||
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"`
|
||||
PendingIncomeRecurrings []PendingIncome `json:"pending_income_recurrings"`
|
||||
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
|
||||
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)
|
||||
}
|
||||
@@ -34,6 +34,14 @@ export interface PendingIncome {
|
||||
late: boolean
|
||||
}
|
||||
|
||||
export interface PendingBillImport {
|
||||
id: number
|
||||
filename: string
|
||||
payment_date: string
|
||||
total: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface DashboardData {
|
||||
month: string
|
||||
total_income: number
|
||||
@@ -46,6 +54,7 @@ export interface DashboardData {
|
||||
pending_recurring: number
|
||||
pending_income_recurrings: PendingIncome[]
|
||||
current_bills: CreditBill[]
|
||||
pending_bill_imports: PendingBillImport[]
|
||||
}
|
||||
|
||||
export const useDashboardStore = defineStore('dashboard', () => {
|
||||
|
||||
@@ -15,6 +15,8 @@ export interface ImportResult {
|
||||
imported: number
|
||||
duplicates: number
|
||||
errors: number
|
||||
pending?: boolean
|
||||
pending_bill?: { id: number; payment_date: string; total: number }
|
||||
}
|
||||
|
||||
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
|
||||
error.value = null
|
||||
try {
|
||||
@@ -74,6 +76,8 @@ export const useImportStore = defineStore('import', () => {
|
||||
filename: filename.value,
|
||||
rows: rows.value,
|
||||
parse_error_count: parseErrors.value.length,
|
||||
is_credit_card: isCreditCard ?? false,
|
||||
payment_date: paymentDate ?? '',
|
||||
}),
|
||||
})
|
||||
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">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useAccountsStore, type AccountInput } from '@/stores/accounts'
|
||||
import { usePendingBillsStore } from '@/stores/pending_bills'
|
||||
import NeonPanel from '@/components/NeonPanel.vue'
|
||||
import CurrencyInput from '@/components/CurrencyInput.vue'
|
||||
|
||||
const store = useAccountsStore()
|
||||
onMounted(() => store.fetchAll())
|
||||
const pendingStore = usePendingBillsStore()
|
||||
onMounted(() => { store.fetchAll(); pendingStore.fetchAll() })
|
||||
|
||||
const typeLabels: Record<string, string> = {
|
||||
checking: 'Conta Corrente',
|
||||
@@ -173,6 +175,34 @@ function totalBalance() {
|
||||
</ul>
|
||||
</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 -->
|
||||
<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'">
|
||||
@@ -327,4 +357,21 @@ function totalBalance() {
|
||||
|
||||
.fc-acc-item__balance { font-size: 16px; font-weight: 700; }
|
||||
.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>
|
||||
|
||||
@@ -76,8 +76,26 @@ function fmt(amount: number) {
|
||||
<div class="fc-view">
|
||||
<span class="fc-pixel fc-view__title">:: IMPORTAR EXTRATO</span>
|
||||
|
||||
<!-- Result banner -->
|
||||
<NeonPanel v-if="store.result" variant="success" title="CONCLUÍDO">
|
||||
<!-- Result banner: pending bill -->
|
||||
<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">
|
||||
<span class="fc-mono fc-import-result__stat">
|
||||
<span class="fc-text-green">{{ store.result.imported }}</span> importadas
|
||||
@@ -243,7 +261,7 @@ function fmt(amount: number) {
|
||||
<button
|
||||
class="fc-btn fc-btn--primary"
|
||||
: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` }}
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user