This commit is contained in:
@@ -70,11 +70,15 @@ func main() {
|
||||
recurringHandler := handler.NewRecurringHandler(recurringSvc)
|
||||
|
||||
accountRepo := repository.NewAccountRepository(pool)
|
||||
accountSvc := service.NewAccountService(accountRepo)
|
||||
cdiSvc := service.NewCDIYieldService(accountRepo, manualTxRepo)
|
||||
creditBillRepo := repository.NewCreditBillRepository(pool)
|
||||
creditBillSvc := service.NewCreditBillService(creditBillRepo, accountRepo)
|
||||
accountSvc := service.NewAccountService(accountRepo, cdiSvc, creditBillSvc)
|
||||
accountHandler := handler.NewAccountHandler(accountSvc)
|
||||
creditBillHandler := handler.NewCreditBillHandler(creditBillSvc)
|
||||
|
||||
dashboardRepo := repository.NewDashboardRepository(pool)
|
||||
dashboardSvc := service.NewDashboardService(dashboardRepo, recurringSvc, accountRepo)
|
||||
dashboardSvc := service.NewDashboardService(dashboardRepo, recurringSvc, accountRepo, creditBillSvc)
|
||||
dashboardHandler := handler.NewDashboardHandler(dashboardSvc)
|
||||
|
||||
r.Get("/health", handler.Health)
|
||||
@@ -105,11 +109,17 @@ func main() {
|
||||
r.Get("/recurring/status", recurringHandler.MonthlyStatus)
|
||||
r.Post("/recurring/{id}/ignore", recurringHandler.Ignore)
|
||||
r.Delete("/recurring/{id}/ignore", recurringHandler.Unignore)
|
||||
r.Post("/recurring/{id}/confirm", recurringHandler.ConfirmIncome)
|
||||
r.Post("/recurring/{id}/late", recurringHandler.MarkLate)
|
||||
|
||||
r.Get("/accounts", accountHandler.List)
|
||||
r.Post("/accounts", accountHandler.Create)
|
||||
r.Put("/accounts/{id}", accountHandler.Update)
|
||||
r.Delete("/accounts/{id}", accountHandler.Delete)
|
||||
r.Get("/accounts/{id}/bills", creditBillHandler.ListByAccount)
|
||||
r.Get("/accounts/{id}/bills/current", creditBillHandler.GetCurrent)
|
||||
r.Post("/accounts/{id}/bills/ensure", creditBillHandler.EnsureCurrent)
|
||||
r.Post("/bills/{id}/pay", creditBillHandler.MarkPaid)
|
||||
|
||||
r.Get("/dashboard", dashboardHandler.Get)
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"financeiro-carvalho/internal/service"
|
||||
)
|
||||
|
||||
type CreditBillHandler struct {
|
||||
svc *service.CreditBillService
|
||||
}
|
||||
|
||||
func NewCreditBillHandler(svc *service.CreditBillService) *CreditBillHandler {
|
||||
return &CreditBillHandler{svc: svc}
|
||||
}
|
||||
|
||||
func (h *CreditBillHandler) ListByAccount(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.Atoi(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
bills, err := h.svc.ListByAccount(r.Context(), id)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to list bills")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, bills)
|
||||
}
|
||||
|
||||
func (h *CreditBillHandler) GetCurrent(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.Atoi(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
bill, err := h.svc.GetCurrent(r.Context(), id)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to get current bill")
|
||||
return
|
||||
}
|
||||
if bill == nil {
|
||||
respondJSON(w, http.StatusOK, nil)
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, bill)
|
||||
}
|
||||
|
||||
func (h *CreditBillHandler) EnsureCurrent(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.Atoi(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
ClosingDay int `json:"closing_day"`
|
||||
DueDay int `json:"due_day"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
if err := h.svc.EnsureCurrentBill(r.Context(), id, body.ClosingDay, body.DueDay); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to ensure bill")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *CreditBillHandler) MarkPaid(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.Atoi(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
PaymentAccountID *int `json:"payment_account_id"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
if err := h.svc.MarkPaid(r.Context(), id, body.PaymentAccountID); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to mark bill paid")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
@@ -128,3 +128,49 @@ func (h *RecurringHandler) Unignore(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *RecurringHandler) ConfirmIncome(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.Atoi(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Month string `json:"month"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
if err := h.svc.ConfirmIncome(r.Context(), id, body.Month); errors.Is(err, repository.ErrNotFound) {
|
||||
respondError(w, http.StatusNotFound, "not found")
|
||||
return
|
||||
} else if err != nil {
|
||||
respondError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *RecurringHandler) MarkLate(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.Atoi(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Month string `json:"month"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
if err := h.svc.MarkLate(r.Context(), id, body.Month); errors.Is(err, repository.ErrNotFound) {
|
||||
respondError(w, http.StatusNotFound, "not found")
|
||||
return
|
||||
} else if err != nil {
|
||||
respondError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
-- Add type to recurring_expenses (income | expense, default expense — retrocompatível)
|
||||
ALTER TABLE recurring_expenses
|
||||
ADD COLUMN IF NOT EXISTS type VARCHAR(10) NOT NULL DEFAULT 'expense'
|
||||
CHECK (type IN ('income', 'expense'));
|
||||
|
||||
-- Track income recurrings marked as "late" for a given month
|
||||
CREATE TABLE IF NOT EXISTS recurring_late (
|
||||
id SERIAL PRIMARY KEY,
|
||||
recurring_id INTEGER NOT NULL REFERENCES recurring_expenses(id) ON DELETE CASCADE,
|
||||
month VARCHAR(7) NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(recurring_id, month)
|
||||
);
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Add CDI yield support to accounts
|
||||
ALTER TABLE accounts
|
||||
ADD COLUMN IF NOT EXISTS yield_type VARCHAR(10) NOT NULL DEFAULT 'none'
|
||||
CHECK (yield_type IN ('none', 'cdi', 'variable')),
|
||||
ADD COLUMN IF NOT EXISTS last_yield_date DATE;
|
||||
|
||||
-- Extend transactions.source check to allow 'yield'
|
||||
ALTER TABLE transactions DROP CONSTRAINT IF EXISTS transactions_source_check;
|
||||
ALTER TABLE transactions
|
||||
ADD CONSTRAINT transactions_source_check
|
||||
CHECK (source IN ('manual', 'import', 'yield'));
|
||||
@@ -0,0 +1,19 @@
|
||||
-- Credit card billing period configuration per account
|
||||
ALTER TABLE accounts
|
||||
ADD COLUMN IF NOT EXISTS closing_day INTEGER CHECK (closing_day BETWEEN 1 AND 28),
|
||||
ADD COLUMN IF NOT EXISTS due_day INTEGER CHECK (due_day BETWEEN 1 AND 28);
|
||||
|
||||
-- Materialized bill metadata (calculated from transactions)
|
||||
-- One row per (account, billing period start date)
|
||||
CREATE TABLE IF NOT EXISTS credit_bills (
|
||||
id SERIAL PRIMARY KEY,
|
||||
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
||||
period_start DATE NOT NULL,
|
||||
period_end DATE NOT NULL,
|
||||
due_date DATE NOT NULL,
|
||||
paid BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
paid_at TIMESTAMP WITH TIME ZONE,
|
||||
payment_account_id INTEGER REFERENCES accounts(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(account_id, period_start)
|
||||
);
|
||||
@@ -6,6 +6,11 @@ type Account struct {
|
||||
Type string `json:"type"`
|
||||
InitialBalance float64 `json:"initial_balance"`
|
||||
Balance float64 `json:"balance"`
|
||||
YieldType string `json:"yield_type"` // "none" | "cdi" | "variable"
|
||||
LastYieldDate *string `json:"last_yield_date,omitempty"`
|
||||
ClosingDay *int `json:"closing_day,omitempty"`
|
||||
DueDay *int `json:"due_day,omitempty"`
|
||||
CurrentBill *CreditBill `json:"current_bill,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
@@ -14,4 +19,7 @@ type AccountInput struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
InitialBalance float64 `json:"initial_balance"`
|
||||
YieldType string `json:"yield_type"` // "none" | "cdi" | "variable"
|
||||
ClosingDay *int `json:"closing_day,omitempty"`
|
||||
DueDay *int `json:"due_day,omitempty"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package model
|
||||
|
||||
type CreditBill struct {
|
||||
ID int `json:"id"`
|
||||
AccountID int `json:"account_id"`
|
||||
AccountName string `json:"account_name"`
|
||||
PeriodStart string `json:"period_start"`
|
||||
PeriodEnd string `json:"period_end"`
|
||||
DueDate string `json:"due_date"`
|
||||
Total float64 `json:"total"`
|
||||
Paid bool `json:"paid"`
|
||||
PaidAt *string `json:"paid_at,omitempty"`
|
||||
PaymentAccountID *int `json:"payment_account_id,omitempty"`
|
||||
}
|
||||
|
||||
type CreditBillInput struct {
|
||||
AccountID int `json:"account_id"`
|
||||
PeriodStart string `json:"period_start"`
|
||||
PeriodEnd string `json:"period_end"`
|
||||
DueDate string `json:"due_date"`
|
||||
PaymentAccountID *int `json:"payment_account_id,omitempty"`
|
||||
}
|
||||
@@ -33,4 +33,6 @@ type DashboardData struct {
|
||||
MonthlyEvolution []MonthEvolution `json:"monthly_evolution"`
|
||||
RecentTransactions []RecentTransaction `json:"recent_transactions"`
|
||||
PendingRecurring int `json:"pending_recurring"`
|
||||
PendingIncomeRecurrings []PendingIncome `json:"pending_income_recurrings"`
|
||||
CurrentBills []CreditBill `json:"current_bills"`
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ type RecurringExpense struct {
|
||||
ExpectedAmount float64 `json:"expected_amount"`
|
||||
DayOfMonth int `json:"day_of_month"`
|
||||
CategoryID *int `json:"category_id"`
|
||||
Type string `json:"type"` // "income" | "expense"
|
||||
Active bool `json:"active"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
@@ -18,12 +19,23 @@ type RecurringInput struct {
|
||||
ExpectedAmount float64 `json:"expected_amount"`
|
||||
DayOfMonth int `json:"day_of_month"`
|
||||
CategoryID *int `json:"category_id"`
|
||||
Type string `json:"type"` // "income" | "expense"
|
||||
}
|
||||
|
||||
// RecurringStatus reports whether a recurring expense is covered for a month.
|
||||
// RecurringStatus reports whether a recurring item is covered/ignored/late for a month.
|
||||
type RecurringStatus struct {
|
||||
RecurringExpense
|
||||
Covered bool `json:"covered"` // has a matching transaction
|
||||
Ignored bool `json:"ignored"` // user explicitly ignored this month
|
||||
Covered bool `json:"covered"` // has a matching transaction or was ignored
|
||||
Ignored bool `json:"ignored"` // user explicitly ignored this month (expense)
|
||||
Late bool `json:"late"` // user marked income as late this month
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// PendingIncome is a lightweight summary of an income recurring pending confirmation.
|
||||
type PendingIncome struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
ExpectedAmount float64 `json:"expected_amount"`
|
||||
DayOfMonth int `json:"day_of_month"`
|
||||
Late bool `json:"late"`
|
||||
}
|
||||
|
||||
@@ -8,6 +8,17 @@ import (
|
||||
"financeiro-carvalho/internal/model"
|
||||
)
|
||||
|
||||
// AccountRepoWithYield extends the basic account repo with CDI-related operations.
|
||||
type AccountRepoWithYield interface {
|
||||
List(ctx context.Context) ([]model.Account, error)
|
||||
GetByID(ctx context.Context, id int) (*model.Account, error)
|
||||
Create(ctx context.Context, in model.AccountInput) (*model.Account, error)
|
||||
Update(ctx context.Context, id int, in model.AccountInput) (*model.Account, error)
|
||||
Delete(ctx context.Context, id int) error
|
||||
TotalPatrimony(ctx context.Context) (float64, error)
|
||||
UpdateLastYieldDate(ctx context.Context, id int, date string) error
|
||||
}
|
||||
|
||||
type AccountRepository struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
@@ -20,6 +31,8 @@ func (r *AccountRepository) List(ctx context.Context) ([]model.Account, error) {
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
SELECT
|
||||
a.id, a.name, a.type, a.initial_balance,
|
||||
a.yield_type, a.last_yield_date::text,
|
||||
a.closing_day, a.due_day,
|
||||
a.created_at::text, a.updated_at::text,
|
||||
a.initial_balance
|
||||
+ COALESCE(SUM(CASE WHEN t.type = 'income' THEN t.amount ELSE 0 END), 0)
|
||||
@@ -38,7 +51,7 @@ func (r *AccountRepository) List(ctx context.Context) ([]model.Account, error) {
|
||||
var out []model.Account
|
||||
for rows.Next() {
|
||||
var a model.Account
|
||||
if err := rows.Scan(&a.ID, &a.Name, &a.Type, &a.InitialBalance, &a.CreatedAt, &a.UpdatedAt, &a.Balance); err != nil {
|
||||
if err := rows.Scan(&a.ID, &a.Name, &a.Type, &a.InitialBalance, &a.YieldType, &a.LastYieldDate, &a.ClosingDay, &a.DueDay, &a.CreatedAt, &a.UpdatedAt, &a.Balance); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, a)
|
||||
@@ -51,6 +64,8 @@ func (r *AccountRepository) GetByID(ctx context.Context, id int) (*model.Account
|
||||
err := r.pool.QueryRow(ctx, `
|
||||
SELECT
|
||||
a.id, a.name, a.type, a.initial_balance,
|
||||
a.yield_type, a.last_yield_date::text,
|
||||
a.closing_day, a.due_day,
|
||||
a.created_at::text, a.updated_at::text,
|
||||
a.initial_balance
|
||||
+ COALESCE(SUM(CASE WHEN t.type = 'income' THEN t.amount ELSE 0 END), 0)
|
||||
@@ -60,7 +75,7 @@ func (r *AccountRepository) GetByID(ctx context.Context, id int) (*model.Account
|
||||
LEFT JOIN transactions t ON t.account_id = a.id
|
||||
WHERE a.id = $1
|
||||
GROUP BY a.id
|
||||
`, id).Scan(&a.ID, &a.Name, &a.Type, &a.InitialBalance, &a.CreatedAt, &a.UpdatedAt, &a.Balance)
|
||||
`, id).Scan(&a.ID, &a.Name, &a.Type, &a.InitialBalance, &a.YieldType, &a.LastYieldDate, &a.ClosingDay, &a.DueDay, &a.CreatedAt, &a.UpdatedAt, &a.Balance)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -68,13 +83,17 @@ func (r *AccountRepository) GetByID(ctx context.Context, id int) (*model.Account
|
||||
}
|
||||
|
||||
func (r *AccountRepository) Create(ctx context.Context, in model.AccountInput) (*model.Account, error) {
|
||||
yt := in.YieldType
|
||||
if yt == "" {
|
||||
yt = "none"
|
||||
}
|
||||
var a model.Account
|
||||
err := r.pool.QueryRow(ctx, `
|
||||
INSERT INTO accounts (name, type, initial_balance)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, name, type, initial_balance, created_at::text, updated_at::text
|
||||
`, in.Name, in.Type, in.InitialBalance).
|
||||
Scan(&a.ID, &a.Name, &a.Type, &a.InitialBalance, &a.CreatedAt, &a.UpdatedAt)
|
||||
INSERT INTO accounts (name, type, initial_balance, yield_type, closing_day, due_day)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id, name, type, initial_balance, yield_type, last_yield_date::text, closing_day, due_day, created_at::text, updated_at::text
|
||||
`, in.Name, in.Type, in.InitialBalance, yt, in.ClosingDay, in.DueDay).
|
||||
Scan(&a.ID, &a.Name, &a.Type, &a.InitialBalance, &a.YieldType, &a.LastYieldDate, &a.ClosingDay, &a.DueDay, &a.CreatedAt, &a.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -83,16 +102,19 @@ func (r *AccountRepository) Create(ctx context.Context, in model.AccountInput) (
|
||||
}
|
||||
|
||||
func (r *AccountRepository) Update(ctx context.Context, id int, in model.AccountInput) (*model.Account, error) {
|
||||
yt := in.YieldType
|
||||
if yt == "" {
|
||||
yt = "none"
|
||||
}
|
||||
row := r.pool.QueryRow(ctx, `
|
||||
UPDATE accounts SET name=$1, type=$2, initial_balance=$3, updated_at=NOW()
|
||||
WHERE id=$4
|
||||
RETURNING id, name, type, initial_balance, created_at::text, updated_at::text
|
||||
`, in.Name, in.Type, in.InitialBalance, id)
|
||||
UPDATE accounts SET name=$1, type=$2, initial_balance=$3, yield_type=$4, closing_day=$5, due_day=$6, updated_at=NOW()
|
||||
WHERE id=$7
|
||||
RETURNING id, name, type, initial_balance, yield_type, last_yield_date::text, closing_day, due_day, created_at::text, updated_at::text
|
||||
`, in.Name, in.Type, in.InitialBalance, yt, in.ClosingDay, in.DueDay, id)
|
||||
var a model.Account
|
||||
if err := row.Scan(&a.ID, &a.Name, &a.Type, &a.InitialBalance, &a.CreatedAt, &a.UpdatedAt); err != nil {
|
||||
if err := row.Scan(&a.ID, &a.Name, &a.Type, &a.InitialBalance, &a.YieldType, &a.LastYieldDate, &a.ClosingDay, &a.DueDay, &a.CreatedAt, &a.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// recalculate balance
|
||||
full, err := r.GetByID(ctx, a.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -119,3 +141,8 @@ func (r *AccountRepository) TotalPatrimony(ctx context.Context) (float64, error)
|
||||
`).Scan(&total)
|
||||
return total, err
|
||||
}
|
||||
|
||||
func (r *AccountRepository) UpdateLastYieldDate(ctx context.Context, id int, date string) error {
|
||||
_, err := r.pool.Exec(ctx, `UPDATE accounts SET last_yield_date = $1 WHERE id = $2`, date, id)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"financeiro-carvalho/internal/model"
|
||||
)
|
||||
|
||||
type CreditBillRepository interface {
|
||||
ListByAccount(ctx context.Context, accountID int) ([]model.CreditBill, error)
|
||||
GetCurrent(ctx context.Context, accountID int) (*model.CreditBill, error)
|
||||
Upsert(ctx context.Context, in model.CreditBillInput) (*model.CreditBill, error)
|
||||
MarkPaid(ctx context.Context, id int, paymentAccountID *int) error
|
||||
}
|
||||
|
||||
type creditBillRepo struct{ db *pgxpool.Pool }
|
||||
|
||||
func NewCreditBillRepository(db *pgxpool.Pool) CreditBillRepository {
|
||||
return &creditBillRepo{db: db}
|
||||
}
|
||||
|
||||
func (r *creditBillRepo) ListByAccount(ctx context.Context, accountID int) ([]model.CreditBill, error) {
|
||||
rows, err := r.db.Query(ctx, `
|
||||
SELECT
|
||||
cb.id, cb.account_id, a.name,
|
||||
cb.period_start::text, cb.period_end::text, cb.due_date::text,
|
||||
COALESCE(SUM(t.amount) FILTER (WHERE t.type = 'expense'), 0) AS total,
|
||||
cb.paid, cb.paid_at::text, cb.payment_account_id
|
||||
FROM credit_bills cb
|
||||
JOIN accounts a ON a.id = cb.account_id
|
||||
LEFT JOIN transactions t ON t.account_id = cb.account_id
|
||||
AND t.date >= cb.period_start AND t.date <= cb.period_end
|
||||
AND t.type = 'expense'
|
||||
WHERE cb.account_id = $1
|
||||
GROUP BY cb.id, a.name
|
||||
ORDER BY cb.period_start DESC
|
||||
`, accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []model.CreditBill
|
||||
for rows.Next() {
|
||||
var b model.CreditBill
|
||||
if err := rows.Scan(&b.ID, &b.AccountID, &b.AccountName, &b.PeriodStart, &b.PeriodEnd, &b.DueDate, &b.Total, &b.Paid, &b.PaidAt, &b.PaymentAccountID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, b)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *creditBillRepo) GetCurrent(ctx context.Context, accountID int) (*model.CreditBill, error) {
|
||||
var b model.CreditBill
|
||||
err := r.db.QueryRow(ctx, `
|
||||
SELECT
|
||||
cb.id, cb.account_id, a.name,
|
||||
cb.period_start::text, cb.period_end::text, cb.due_date::text,
|
||||
COALESCE(SUM(t.amount) FILTER (WHERE t.type = 'expense'), 0) AS total,
|
||||
cb.paid, cb.paid_at::text, cb.payment_account_id
|
||||
FROM credit_bills cb
|
||||
JOIN accounts a ON a.id = cb.account_id
|
||||
LEFT JOIN transactions t ON t.account_id = cb.account_id
|
||||
AND t.date >= cb.period_start AND t.date <= cb.period_end
|
||||
AND t.type = 'expense'
|
||||
WHERE cb.account_id = $1 AND cb.paid = FALSE
|
||||
GROUP BY cb.id, a.name
|
||||
ORDER BY cb.period_start DESC
|
||||
LIMIT 1
|
||||
`, accountID).Scan(&b.ID, &b.AccountID, &b.AccountName, &b.PeriodStart, &b.PeriodEnd, &b.DueDate, &b.Total, &b.Paid, &b.PaidAt, &b.PaymentAccountID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return &b, err
|
||||
}
|
||||
|
||||
func (r *creditBillRepo) Upsert(ctx context.Context, in model.CreditBillInput) (*model.CreditBill, error) {
|
||||
var b model.CreditBill
|
||||
err := r.db.QueryRow(ctx, `
|
||||
INSERT INTO credit_bills (account_id, period_start, period_end, due_date, payment_account_id)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (account_id, period_start) DO UPDATE
|
||||
SET period_end = EXCLUDED.period_end,
|
||||
due_date = EXCLUDED.due_date,
|
||||
payment_account_id = EXCLUDED.payment_account_id
|
||||
RETURNING id, account_id, period_start::text, period_end::text, due_date::text, paid, paid_at::text, payment_account_id
|
||||
`, in.AccountID, in.PeriodStart, in.PeriodEnd, in.DueDate, in.PaymentAccountID).
|
||||
Scan(&b.ID, &b.AccountID, &b.PeriodStart, &b.PeriodEnd, &b.DueDate, &b.Paid, &b.PaidAt, &b.PaymentAccountID)
|
||||
return &b, err
|
||||
}
|
||||
|
||||
func (r *creditBillRepo) MarkPaid(ctx context.Context, id int, paymentAccountID *int) error {
|
||||
_, err := r.db.Exec(ctx, `
|
||||
UPDATE credit_bills
|
||||
SET paid = TRUE, paid_at = NOW(), payment_account_id = $2
|
||||
WHERE id = $1
|
||||
`, id, paymentAccountID)
|
||||
return err
|
||||
}
|
||||
@@ -19,6 +19,9 @@ type RecurringRepository interface {
|
||||
IsIgnored(ctx context.Context, id int, month string) (bool, string, error)
|
||||
Ignore(ctx context.Context, id int, month, reason string) error
|
||||
Unignore(ctx context.Context, id int, month string) error
|
||||
IsLate(ctx context.Context, id int, month string) (bool, error)
|
||||
MarkLate(ctx context.Context, id int, month string) error
|
||||
UnmarkLate(ctx context.Context, id int, month string) error
|
||||
}
|
||||
|
||||
type recurringRepo struct{ db *pgxpool.Pool }
|
||||
@@ -29,7 +32,7 @@ func NewRecurringRepository(db *pgxpool.Pool) RecurringRepository {
|
||||
|
||||
func (r *recurringRepo) List(ctx context.Context) ([]model.RecurringExpense, error) {
|
||||
rows, err := r.db.Query(ctx, `
|
||||
SELECT id, name, expected_amount, day_of_month, category_id, active, created_at, updated_at
|
||||
SELECT id, name, expected_amount, day_of_month, category_id, type, active, created_at, updated_at
|
||||
FROM recurring_expenses ORDER BY name ASC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -38,7 +41,7 @@ func (r *recurringRepo) List(ctx context.Context) ([]model.RecurringExpense, err
|
||||
var out []model.RecurringExpense
|
||||
for rows.Next() {
|
||||
var re model.RecurringExpense
|
||||
if err := rows.Scan(&re.ID, &re.Name, &re.ExpectedAmount, &re.DayOfMonth, &re.CategoryID, &re.Active, &re.CreatedAt, &re.UpdatedAt); err != nil {
|
||||
if err := rows.Scan(&re.ID, &re.Name, &re.ExpectedAmount, &re.DayOfMonth, &re.CategoryID, &re.Type, &re.Active, &re.CreatedAt, &re.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, re)
|
||||
@@ -49,9 +52,9 @@ func (r *recurringRepo) List(ctx context.Context) ([]model.RecurringExpense, err
|
||||
func (r *recurringRepo) GetByID(ctx context.Context, id int) (*model.RecurringExpense, error) {
|
||||
var re model.RecurringExpense
|
||||
err := r.db.QueryRow(ctx, `
|
||||
SELECT id, name, expected_amount, day_of_month, category_id, active, created_at, updated_at
|
||||
SELECT id, name, expected_amount, day_of_month, category_id, type, active, created_at, updated_at
|
||||
FROM recurring_expenses WHERE id = $1`, id).
|
||||
Scan(&re.ID, &re.Name, &re.ExpectedAmount, &re.DayOfMonth, &re.CategoryID, &re.Active, &re.CreatedAt, &re.UpdatedAt)
|
||||
Scan(&re.ID, &re.Name, &re.ExpectedAmount, &re.DayOfMonth, &re.CategoryID, &re.Type, &re.Active, &re.CreatedAt, &re.UpdatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
@@ -59,25 +62,33 @@ func (r *recurringRepo) GetByID(ctx context.Context, id int) (*model.RecurringEx
|
||||
}
|
||||
|
||||
func (r *recurringRepo) Create(ctx context.Context, in model.RecurringInput) (*model.RecurringExpense, error) {
|
||||
t := in.Type
|
||||
if t == "" {
|
||||
t = "expense"
|
||||
}
|
||||
var re model.RecurringExpense
|
||||
err := r.db.QueryRow(ctx, `
|
||||
INSERT INTO recurring_expenses (name, expected_amount, day_of_month, category_id)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, name, expected_amount, day_of_month, category_id, active, created_at, updated_at`,
|
||||
in.Name, in.ExpectedAmount, in.DayOfMonth, in.CategoryID).
|
||||
Scan(&re.ID, &re.Name, &re.ExpectedAmount, &re.DayOfMonth, &re.CategoryID, &re.Active, &re.CreatedAt, &re.UpdatedAt)
|
||||
INSERT INTO recurring_expenses (name, expected_amount, day_of_month, category_id, type)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, name, expected_amount, day_of_month, category_id, type, active, created_at, updated_at`,
|
||||
in.Name, in.ExpectedAmount, in.DayOfMonth, in.CategoryID, t).
|
||||
Scan(&re.ID, &re.Name, &re.ExpectedAmount, &re.DayOfMonth, &re.CategoryID, &re.Type, &re.Active, &re.CreatedAt, &re.UpdatedAt)
|
||||
return &re, err
|
||||
}
|
||||
|
||||
func (r *recurringRepo) Update(ctx context.Context, id int, in model.RecurringInput) (*model.RecurringExpense, error) {
|
||||
t := in.Type
|
||||
if t == "" {
|
||||
t = "expense"
|
||||
}
|
||||
var re model.RecurringExpense
|
||||
err := r.db.QueryRow(ctx, `
|
||||
UPDATE recurring_expenses
|
||||
SET name = $1, expected_amount = $2, day_of_month = $3, category_id = $4, updated_at = NOW()
|
||||
WHERE id = $5
|
||||
RETURNING id, name, expected_amount, day_of_month, category_id, active, created_at, updated_at`,
|
||||
in.Name, in.ExpectedAmount, in.DayOfMonth, in.CategoryID, id).
|
||||
Scan(&re.ID, &re.Name, &re.ExpectedAmount, &re.DayOfMonth, &re.CategoryID, &re.Active, &re.CreatedAt, &re.UpdatedAt)
|
||||
SET name = $1, expected_amount = $2, day_of_month = $3, category_id = $4, type = $5, updated_at = NOW()
|
||||
WHERE id = $6
|
||||
RETURNING id, name, expected_amount, day_of_month, category_id, type, active, created_at, updated_at`,
|
||||
in.Name, in.ExpectedAmount, in.DayOfMonth, in.CategoryID, t, id).
|
||||
Scan(&re.ID, &re.Name, &re.ExpectedAmount, &re.DayOfMonth, &re.CategoryID, &re.Type, &re.Active, &re.CreatedAt, &re.UpdatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
@@ -119,3 +130,28 @@ func (r *recurringRepo) Unignore(ctx context.Context, id int, month string) erro
|
||||
_, err := r.db.Exec(ctx, `DELETE FROM recurring_ignores WHERE recurring_id = $1 AND month = $2`, id, month)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *recurringRepo) IsLate(ctx context.Context, id int, month string) (bool, error) {
|
||||
var count int
|
||||
err := r.db.QueryRow(ctx,
|
||||
`SELECT COUNT(*) FROM recurring_late WHERE recurring_id = $1 AND month = $2`,
|
||||
id, month).Scan(&count)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func (r *recurringRepo) MarkLate(ctx context.Context, id int, month string) error {
|
||||
_, err := r.db.Exec(ctx, `
|
||||
INSERT INTO recurring_late (recurring_id, month)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (recurring_id, month) DO NOTHING`,
|
||||
id, month)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *recurringRepo) UnmarkLate(ctx context.Context, id int, month string) error {
|
||||
_, err := r.db.Exec(ctx, `DELETE FROM recurring_late WHERE recurring_id = $1 AND month = $2`, id, month)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -16,8 +16,8 @@ type ManualTransactionRepository interface {
|
||||
Create(ctx context.Context, t model.Transaction) (*model.Transaction, error)
|
||||
Update(ctx context.Context, t model.Transaction) (*model.Transaction, error)
|
||||
Delete(ctx context.Context, id int) error
|
||||
// HasMatchingTransaction checks if a category has an expense in the given month.
|
||||
HasMatchingTransaction(ctx context.Context, categoryID *int, month string, amount float64) (bool, error)
|
||||
// HasMatchingTransaction checks if a category has a transaction of the given type in the given month.
|
||||
HasMatchingTransaction(ctx context.Context, categoryID *int, month string, amount float64, txType string) (bool, error)
|
||||
}
|
||||
|
||||
type manualTxRepo struct{ db *pgxpool.Pool }
|
||||
@@ -103,20 +103,18 @@ func (r *manualTxRepo) Delete(ctx context.Context, id int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *manualTxRepo) HasMatchingTransaction(ctx context.Context, categoryID *int, month string, amount float64) (bool, error) {
|
||||
var count int
|
||||
var err error
|
||||
if categoryID != nil {
|
||||
err = r.db.QueryRow(ctx, `
|
||||
SELECT COUNT(*) FROM transactions
|
||||
WHERE category_id = $1
|
||||
AND TO_CHAR(date, 'YYYY-MM') = $2
|
||||
AND type = 'expense'
|
||||
AND amount BETWEEN $3 * 0.9 AND $3 * 1.1`,
|
||||
*categoryID, month, amount).Scan(&count)
|
||||
} else {
|
||||
func (r *manualTxRepo) HasMatchingTransaction(ctx context.Context, categoryID *int, month string, amount float64, txType string) (bool, error) {
|
||||
if categoryID == nil {
|
||||
// No category set — cannot auto-match, always report as uncovered
|
||||
return false, nil
|
||||
}
|
||||
var count int
|
||||
err := r.db.QueryRow(ctx, `
|
||||
SELECT COUNT(*) FROM transactions
|
||||
WHERE category_id = $1
|
||||
AND TO_CHAR(date, 'YYYY-MM') = $2
|
||||
AND type = $3
|
||||
AND amount BETWEEN $4 * 0.9 AND $4 * 1.1`,
|
||||
*categoryID, month, txType, amount).Scan(&count)
|
||||
return count > 0, err
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
|
||||
"financeiro-carvalho/internal/model"
|
||||
"financeiro-carvalho/internal/repository"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -26,11 +27,13 @@ type AccountRepo interface {
|
||||
}
|
||||
|
||||
type AccountService struct {
|
||||
repo AccountRepo
|
||||
repo repository.AccountRepoWithYield
|
||||
cdiSvc *CDIYieldService
|
||||
billSvc *CreditBillService
|
||||
}
|
||||
|
||||
func NewAccountService(repo AccountRepo) *AccountService {
|
||||
return &AccountService{repo: repo}
|
||||
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 {
|
||||
@@ -43,8 +46,44 @@ func (s *AccountService) validate(in model.AccountInput) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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) {
|
||||
return s.repo.List(ctx)
|
||||
accounts, err := s.repo.List(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
yieldApplied := false
|
||||
for i := range accounts {
|
||||
if accounts[i].YieldType == "cdi" {
|
||||
_ = s.cdiSvc.ApplyYield(ctx, &accounts[i])
|
||||
yieldApplied = true
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
@@ -39,9 +39,30 @@ func (m *mockAccountRepo) Update(_ context.Context, id int, in model.AccountInpu
|
||||
}
|
||||
func (m *mockAccountRepo) Delete(_ context.Context, _ int) error { return nil }
|
||||
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{})
|
||||
billSvc := service.NewCreditBillService(&mockCreditBillRepo{}, repo)
|
||||
return service.NewAccountService(repo, cdiSvc, billSvc)
|
||||
}
|
||||
|
||||
func TestCreateAccount_EmptyName(t *testing.T) {
|
||||
svc := service.NewAccountService(&mockAccountRepo{})
|
||||
svc := newAccountSvc()
|
||||
_, err := svc.Create(context.Background(), model.AccountInput{Name: "", Type: "checking", InitialBalance: 0})
|
||||
if err != service.ErrAccountEmptyName {
|
||||
t.Fatalf("expected ErrAccountEmptyName, got %v", err)
|
||||
@@ -49,7 +70,7 @@ func TestCreateAccount_EmptyName(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCreateAccount_InvalidType(t *testing.T) {
|
||||
svc := service.NewAccountService(&mockAccountRepo{})
|
||||
svc := newAccountSvc()
|
||||
_, err := svc.Create(context.Background(), model.AccountInput{Name: "Nubank", Type: "bitcoin"})
|
||||
if err != service.ErrAccountInvalidType {
|
||||
t.Fatalf("expected ErrAccountInvalidType, got %v", err)
|
||||
@@ -57,7 +78,7 @@ func TestCreateAccount_InvalidType(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCreateAccount_OK(t *testing.T) {
|
||||
svc := service.NewAccountService(&mockAccountRepo{})
|
||||
svc := newAccountSvc()
|
||||
a, err := svc.Create(context.Background(), model.AccountInput{Name: "Nubank", Type: "checking", InitialBalance: 1000})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"financeiro-carvalho/internal/model"
|
||||
"financeiro-carvalho/internal/repository"
|
||||
)
|
||||
|
||||
const bcbCDIURL = "https://api.bcb.gov.br/dados/serie/bcdata.sgs.12/dados?formato=json&dataInicial=%s&dataFinal=%s"
|
||||
|
||||
type CDIYieldService struct {
|
||||
accountRepo repository.AccountRepoWithYield
|
||||
txRepo repository.ManualTransactionRepository
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func NewCDIYieldService(accountRepo repository.AccountRepoWithYield, txRepo repository.ManualTransactionRepository) *CDIYieldService {
|
||||
return &CDIYieldService{
|
||||
accountRepo: accountRepo,
|
||||
txRepo: txRepo,
|
||||
client: &http.Client{Timeout: 10 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// ApplyYield fetches CDI rates and creates a yield transaction for the account if applicable.
|
||||
func (s *CDIYieldService) ApplyYield(ctx context.Context, a *model.Account) error {
|
||||
if a.YieldType != "cdi" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Determine the start date (day after last yield)
|
||||
yesterday := time.Now().AddDate(0, 0, -1).Format("2006-01-02")
|
||||
startDate := a.CreatedAt[:10] // default: account creation date
|
||||
if a.LastYieldDate != nil && *a.LastYieldDate != "" {
|
||||
startDate = *a.LastYieldDate
|
||||
// start is exclusive: add one day
|
||||
t, err := time.Parse("2006-01-02", startDate)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
startDate = t.AddDate(0, 0, 1).Format("2006-01-02")
|
||||
}
|
||||
|
||||
if startDate > yesterday {
|
||||
// nothing to calculate yet
|
||||
return nil
|
||||
}
|
||||
|
||||
rates, err := s.fetchCDIRates(startDate, yesterday)
|
||||
if err != nil {
|
||||
// BCB API unreachable — skip silently; will retry next load
|
||||
return nil
|
||||
}
|
||||
if len(rates) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Fill gaps (weekends/holidays) using last known rate
|
||||
rateMap := make(map[string]float64, len(rates))
|
||||
for _, r := range rates {
|
||||
rateMap[r.date] = r.value
|
||||
}
|
||||
|
||||
compound := 1.0
|
||||
lastRate := rates[0].value
|
||||
start, _ := time.Parse("2006-01-02", startDate)
|
||||
end, _ := time.Parse("2006-01-02", yesterday)
|
||||
for d := start; !d.After(end); d = d.AddDate(0, 0, 1) {
|
||||
key := d.Format("2006-01-02")
|
||||
if r, ok := rateMap[key]; ok {
|
||||
lastRate = r
|
||||
}
|
||||
compound *= 1 + lastRate/100
|
||||
}
|
||||
|
||||
yieldAmount := a.Balance * (compound - 1)
|
||||
if yieldAmount <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
period := fmt.Sprintf("%s → %s", startDate, yesterday)
|
||||
tx := model.Transaction{
|
||||
Date: time.Now().Format("2006-01-02"),
|
||||
Amount: yieldAmount,
|
||||
Description: fmt.Sprintf("Rendimento CDI — %s", period),
|
||||
Type: "income",
|
||||
Source: "yield",
|
||||
AccountID: &a.ID,
|
||||
}
|
||||
if _, err := s.txRepo.Create(ctx, tx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.accountRepo.UpdateLastYieldDate(ctx, a.ID, yesterday)
|
||||
}
|
||||
|
||||
type bcbEntry struct {
|
||||
date string
|
||||
value float64
|
||||
}
|
||||
|
||||
func (s *CDIYieldService) fetchCDIRates(startDate, endDate string) ([]bcbEntry, error) {
|
||||
// BCB API uses DD/MM/YYYY format
|
||||
start, _ := time.Parse("2006-01-02", startDate)
|
||||
end, _ := time.Parse("2006-01-02", endDate)
|
||||
url := fmt.Sprintf(bcbCDIURL, start.Format("02/01/2006"), end.Format("02/01/2006"))
|
||||
|
||||
resp, err := s.client.Get(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var raw []struct {
|
||||
Data string `json:"data"`
|
||||
Valor string `json:"valor"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var out []bcbEntry
|
||||
for _, r := range raw {
|
||||
// BCB date format: DD/MM/YYYY → convert to YYYY-MM-DD
|
||||
t, err := time.Parse("02/01/2006", r.Data)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var v float64
|
||||
fmt.Sscanf(r.Valor, "%f", &v)
|
||||
out = append(out, bcbEntry{date: t.Format("2006-01-02"), value: v})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -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) {
|
||||
@@ -58,11 +59,27 @@ func (s *DashboardService) Get(ctx context.Context, month string) (*model.Dashbo
|
||||
return nil, err
|
||||
}
|
||||
pending := 0
|
||||
for _, s := range statuses {
|
||||
if !s.Covered {
|
||||
var pendingIncome []model.PendingIncome
|
||||
for _, st := range statuses {
|
||||
if st.Type == "income" {
|
||||
if !st.Covered {
|
||||
pendingIncome = append(pendingIncome, model.PendingIncome{
|
||||
ID: st.ID,
|
||||
Name: st.Name,
|
||||
ExpectedAmount: st.ExpectedAmount,
|
||||
DayOfMonth: st.DayOfMonth,
|
||||
Late: st.Late,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
if !st.Covered {
|
||||
pending++
|
||||
}
|
||||
}
|
||||
}
|
||||
if pendingIncome == nil {
|
||||
pendingIncome = []model.PendingIncome{}
|
||||
}
|
||||
|
||||
if byCategory == nil {
|
||||
byCategory = []model.CategoryTotal{}
|
||||
@@ -79,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,
|
||||
@@ -89,5 +111,7 @@ func (s *DashboardService) Get(ctx context.Context, month string) (*model.Dashbo
|
||||
MonthlyEvolution: evolution,
|
||||
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) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"financeiro-carvalho/internal/model"
|
||||
"financeiro-carvalho/internal/repository"
|
||||
@@ -13,6 +14,7 @@ var (
|
||||
ErrRecurringEmptyName = errors.New("name is required")
|
||||
ErrRecurringInvalidAmount = errors.New("expected_amount must be greater than zero")
|
||||
ErrRecurringInvalidDayOfMonth = errors.New("day_of_month must be between 1 and 31")
|
||||
ErrRecurringNotIncome = errors.New("recurring item is not of type income")
|
||||
)
|
||||
|
||||
type RecurringService struct {
|
||||
@@ -52,7 +54,7 @@ func (s *RecurringService) Delete(ctx context.Context, id int) error {
|
||||
return s.repo.Delete(ctx, id)
|
||||
}
|
||||
|
||||
// MonthlyStatus checks which active recurring expenses are covered/ignored for the given month (YYYY-MM).
|
||||
// MonthlyStatus checks which active recurring items are covered/ignored/late for the given month (YYYY-MM).
|
||||
func (s *RecurringService) MonthlyStatus(ctx context.Context, month string) ([]model.RecurringStatus, error) {
|
||||
items, err := s.repo.List(ctx)
|
||||
if err != nil {
|
||||
@@ -64,13 +66,20 @@ func (s *RecurringService) MonthlyStatus(ctx context.Context, month string) ([]m
|
||||
if !re.Active {
|
||||
continue
|
||||
}
|
||||
|
||||
txType := re.Type
|
||||
if txType == "" {
|
||||
txType = "expense"
|
||||
}
|
||||
|
||||
if txType == "expense" {
|
||||
ignored, reason, err := s.repo.IsIgnored(ctx, re.ID, month)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
covered := false
|
||||
if !ignored {
|
||||
covered, err = s.txRepo.HasMatchingTransaction(ctx, re.CategoryID, month, re.ExpectedAmount)
|
||||
covered, err = s.txRepo.HasMatchingTransaction(ctx, re.CategoryID, month, re.ExpectedAmount, "expense")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -81,10 +90,58 @@ func (s *RecurringService) MonthlyStatus(ctx context.Context, month string) ([]m
|
||||
Ignored: ignored,
|
||||
Reason: reason,
|
||||
})
|
||||
} else {
|
||||
// income type
|
||||
late, err := s.repo.IsLate(ctx, re.ID, month)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
covered, err := s.txRepo.HasMatchingTransaction(ctx, re.CategoryID, month, re.ExpectedAmount, "income")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, model.RecurringStatus{
|
||||
RecurringExpense: re,
|
||||
Covered: covered,
|
||||
Late: late,
|
||||
})
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ConfirmIncome creates an income transaction confirming receipt of this recurring income.
|
||||
func (s *RecurringService) ConfirmIncome(ctx context.Context, id int, month string) error {
|
||||
re, err := s.repo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if re.Type != "income" {
|
||||
return ErrRecurringNotIncome
|
||||
}
|
||||
today := time.Now().Format("2006-01-02")
|
||||
tx := model.Transaction{
|
||||
Date: today,
|
||||
Amount: re.ExpectedAmount,
|
||||
Description: re.Name,
|
||||
Type: "income",
|
||||
CategoryID: re.CategoryID,
|
||||
}
|
||||
_, err = s.txRepo.Create(ctx, tx)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *RecurringService) MarkLate(ctx context.Context, id int, month string) error {
|
||||
re, err := s.repo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if re.Type != "income" {
|
||||
return ErrRecurringNotIncome
|
||||
}
|
||||
return s.repo.MarkLate(ctx, id, month)
|
||||
}
|
||||
|
||||
func (s *RecurringService) Ignore(ctx context.Context, id int, month, reason string) error {
|
||||
return s.repo.Ignore(ctx, id, month, reason)
|
||||
}
|
||||
|
||||
@@ -14,10 +14,11 @@ import (
|
||||
type mockRecurringRepo struct {
|
||||
items []model.RecurringExpense
|
||||
ignores map[string]string // "id:month" → reason
|
||||
lates map[string]bool // "id:month" → true
|
||||
}
|
||||
|
||||
func newMockRecurring(items []model.RecurringExpense) *mockRecurringRepo {
|
||||
return &mockRecurringRepo{items: items, ignores: map[string]string{}}
|
||||
return &mockRecurringRepo{items: items, ignores: map[string]string{}, lates: map[string]bool{}}
|
||||
}
|
||||
|
||||
func (m *mockRecurringRepo) List(_ context.Context) ([]model.RecurringExpense, error) {
|
||||
@@ -33,7 +34,7 @@ func (m *mockRecurringRepo) GetByID(_ context.Context, id int) (*model.Recurring
|
||||
return nil, repository.ErrNotFound
|
||||
}
|
||||
func (m *mockRecurringRepo) Create(_ context.Context, in model.RecurringInput) (*model.RecurringExpense, error) {
|
||||
r := model.RecurringExpense{ID: len(m.items) + 1, Name: in.Name, ExpectedAmount: in.ExpectedAmount, DayOfMonth: in.DayOfMonth, Active: true}
|
||||
r := model.RecurringExpense{ID: len(m.items) + 1, Name: in.Name, ExpectedAmount: in.ExpectedAmount, DayOfMonth: in.DayOfMonth, Type: in.Type, Active: true}
|
||||
m.items = append(m.items, r)
|
||||
return &r, nil
|
||||
}
|
||||
@@ -62,6 +63,17 @@ func (m *mockRecurringRepo) Unignore(_ context.Context, id int, month string) er
|
||||
delete(m.ignores, string(rune(id))+":"+month)
|
||||
return nil
|
||||
}
|
||||
func (m *mockRecurringRepo) IsLate(_ context.Context, id int, month string) (bool, error) {
|
||||
return m.lates[string(rune(id))+":"+month], nil
|
||||
}
|
||||
func (m *mockRecurringRepo) MarkLate(_ context.Context, id int, month string) error {
|
||||
m.lates[string(rune(id))+":"+month] = true
|
||||
return nil
|
||||
}
|
||||
func (m *mockRecurringRepo) UnmarkLate(_ context.Context, id int, month string) error {
|
||||
delete(m.lates, string(rune(id))+":"+month)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── mock tx repo ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -78,7 +90,7 @@ func (m *mockTxRepo) Update(_ context.Context, t model.Transaction) (*model.Tran
|
||||
return &t, nil
|
||||
}
|
||||
func (m *mockTxRepo) Delete(_ context.Context, _ int) error { return nil }
|
||||
func (m *mockTxRepo) HasMatchingTransaction(_ context.Context, _ *int, _ string, _ float64) (bool, error) {
|
||||
func (m *mockTxRepo) HasMatchingTransaction(_ context.Context, _ *int, _ string, _ float64, _ string) (bool, error) {
|
||||
return m.matchResult, nil
|
||||
}
|
||||
|
||||
@@ -87,7 +99,7 @@ func (m *mockTxRepo) HasMatchingTransaction(_ context.Context, _ *int, _ string,
|
||||
var catID = 1
|
||||
|
||||
func TestMonthlyStatus_Covered(t *testing.T) {
|
||||
re := model.RecurringExpense{ID: 1, Name: "Plano Saúde", ExpectedAmount: 300, DayOfMonth: 5, CategoryID: &catID, Active: true}
|
||||
re := model.RecurringExpense{ID: 1, Name: "Plano Saúde", ExpectedAmount: 300, DayOfMonth: 5, CategoryID: &catID, Type: "expense", Active: true}
|
||||
svc := service.NewRecurringService(newMockRecurring([]model.RecurringExpense{re}), &mockTxRepo{matchResult: true})
|
||||
|
||||
statuses, err := svc.MonthlyStatus(context.Background(), "2024-03")
|
||||
@@ -103,7 +115,7 @@ func TestMonthlyStatus_Covered(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestMonthlyStatus_Uncovered(t *testing.T) {
|
||||
re := model.RecurringExpense{ID: 2, Name: "Netflix", ExpectedAmount: 55, DayOfMonth: 10, CategoryID: &catID, Active: true}
|
||||
re := model.RecurringExpense{ID: 2, Name: "Netflix", ExpectedAmount: 55, DayOfMonth: 10, CategoryID: &catID, Type: "expense", Active: true}
|
||||
svc := service.NewRecurringService(newMockRecurring([]model.RecurringExpense{re}), &mockTxRepo{matchResult: false})
|
||||
|
||||
statuses, err := svc.MonthlyStatus(context.Background(), "2024-03")
|
||||
@@ -116,7 +128,7 @@ func TestMonthlyStatus_Uncovered(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestMonthlyStatus_Ignored_CountsAsCovered(t *testing.T) {
|
||||
re := model.RecurringExpense{ID: 3, Name: "Seguro", ExpectedAmount: 200, DayOfMonth: 1, CategoryID: &catID, Active: true}
|
||||
re := model.RecurringExpense{ID: 3, Name: "Seguro", ExpectedAmount: 200, DayOfMonth: 1, CategoryID: &catID, Type: "expense", Active: true}
|
||||
repo := newMockRecurring([]model.RecurringExpense{re})
|
||||
_ = repo.Ignore(context.Background(), 3, "2024-03", "viagem")
|
||||
svc := service.NewRecurringService(repo, &mockTxRepo{matchResult: false})
|
||||
@@ -133,6 +145,60 @@ func TestMonthlyStatus_Ignored_CountsAsCovered(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonthlyStatus_IncomeConfirmed(t *testing.T) {
|
||||
re := model.RecurringExpense{ID: 4, Name: "Salário", ExpectedAmount: 3200, DayOfMonth: 5, CategoryID: &catID, Type: "income", Active: true}
|
||||
svc := service.NewRecurringService(newMockRecurring([]model.RecurringExpense{re}), &mockTxRepo{matchResult: true})
|
||||
|
||||
statuses, err := svc.MonthlyStatus(context.Background(), "2024-03")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(statuses) != 1 {
|
||||
t.Fatalf("expected 1, got %d", len(statuses))
|
||||
}
|
||||
if !statuses[0].Covered {
|
||||
t.Error("income with matching transaction should be covered")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonthlyStatus_IncomeLate(t *testing.T) {
|
||||
re := model.RecurringExpense{ID: 5, Name: "Salário", ExpectedAmount: 3200, DayOfMonth: 5, CategoryID: &catID, Type: "income", Active: true}
|
||||
repo := newMockRecurring([]model.RecurringExpense{re})
|
||||
_ = repo.MarkLate(context.Background(), 5, "2024-03")
|
||||
svc := service.NewRecurringService(repo, &mockTxRepo{matchResult: false})
|
||||
|
||||
statuses, err := svc.MonthlyStatus(context.Background(), "2024-03")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if statuses[0].Covered {
|
||||
t.Error("late income should NOT be covered")
|
||||
}
|
||||
if !statuses[0].Late {
|
||||
t.Error("expected Late=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfirmIncome_CreatesTransaction(t *testing.T) {
|
||||
re := model.RecurringExpense{ID: 6, Name: "Salário", ExpectedAmount: 3200, DayOfMonth: 5, CategoryID: &catID, Type: "income", Active: true}
|
||||
svc := service.NewRecurringService(newMockRecurring([]model.RecurringExpense{re}), &mockTxRepo{})
|
||||
|
||||
err := svc.ConfirmIncome(context.Background(), 6, "2024-03")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfirmIncome_RejectsExpenseType(t *testing.T) {
|
||||
re := model.RecurringExpense{ID: 7, Name: "Netflix", ExpectedAmount: 55, DayOfMonth: 10, CategoryID: &catID, Type: "expense", Active: true}
|
||||
svc := service.NewRecurringService(newMockRecurring([]model.RecurringExpense{re}), &mockTxRepo{})
|
||||
|
||||
err := svc.ConfirmIncome(context.Background(), 7, "2024-03")
|
||||
if err != service.ErrRecurringNotIncome {
|
||||
t.Fatalf("expected ErrRecurringNotIncome, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateRecurring_Validation(t *testing.T) {
|
||||
svc := service.NewRecurringService(newMockRecurring(nil), &mockTxRepo{})
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ const navItems = [
|
||||
{ to: '/importar', label: 'IMPORT' },
|
||||
{ to: '/contas', label: 'CONTAS' },
|
||||
{ to: '/personagem', label: 'PERS.' },
|
||||
{ to: '/configuracoes', label: 'CFG' },
|
||||
{ to: '/recorrencias', label: 'REC.' },
|
||||
]
|
||||
</script>
|
||||
|
||||
|
||||
@@ -41,10 +41,14 @@ const router = createRouter({
|
||||
name: 'character',
|
||||
component: () => import('../views/CharacterView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/recorrencias',
|
||||
name: 'recurring',
|
||||
component: () => import('../views/RecurringView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/configuracoes',
|
||||
name: 'settings',
|
||||
component: () => import('../views/SettingsView.vue'),
|
||||
redirect: '/recorrencias',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
@@ -2,12 +2,30 @@ import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { api } from '@/services/api'
|
||||
|
||||
export interface CreditBill {
|
||||
id: number
|
||||
account_id: number
|
||||
account_name: string
|
||||
period_start: string
|
||||
period_end: string
|
||||
due_date: string
|
||||
total: number
|
||||
paid: boolean
|
||||
paid_at?: string
|
||||
payment_account_id?: number
|
||||
}
|
||||
|
||||
export interface Account {
|
||||
id: number
|
||||
name: string
|
||||
type: 'checking' | 'savings' | 'investment' | 'credit'
|
||||
initial_balance: number
|
||||
balance: number
|
||||
yield_type: 'none' | 'cdi' | 'variable'
|
||||
last_yield_date?: string
|
||||
closing_day?: number
|
||||
due_day?: number
|
||||
current_bill?: CreditBill
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
@@ -16,6 +34,9 @@ export interface AccountInput {
|
||||
name: string
|
||||
type: string
|
||||
initial_balance: number
|
||||
yield_type: string
|
||||
closing_day?: number | null
|
||||
due_day?: number | null
|
||||
}
|
||||
|
||||
export const useAccountsStore = defineStore('accounts', () => {
|
||||
@@ -53,5 +74,10 @@ export const useAccountsStore = defineStore('accounts', () => {
|
||||
accounts.value = accounts.value.filter((a) => a.id !== id)
|
||||
}
|
||||
|
||||
return { accounts, loading, error, fetchAll, create, update, remove }
|
||||
async function payBill(billId: number, paymentAccountId?: number) {
|
||||
await api.post(`/bills/${billId}/pay`, { payment_account_id: paymentAccountId ?? null })
|
||||
await fetchAll()
|
||||
}
|
||||
|
||||
return { accounts, loading, error, fetchAll, create, update, remove, payBill }
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { api } from '@/services/api'
|
||||
import type { CreditBill } from './accounts'
|
||||
|
||||
export interface CategoryTotal {
|
||||
category_id: number | null
|
||||
@@ -25,6 +26,14 @@ export interface RecentTransaction {
|
||||
type: 'income' | 'expense'
|
||||
}
|
||||
|
||||
export interface PendingIncome {
|
||||
id: number
|
||||
name: string
|
||||
expected_amount: number
|
||||
day_of_month: number
|
||||
late: boolean
|
||||
}
|
||||
|
||||
export interface DashboardData {
|
||||
month: string
|
||||
total_income: number
|
||||
@@ -35,6 +44,8 @@ export interface DashboardData {
|
||||
monthly_evolution: MonthEvolution[]
|
||||
recent_transactions: RecentTransaction[]
|
||||
pending_recurring: number
|
||||
pending_income_recurrings: PendingIncome[]
|
||||
current_bills: CreditBill[]
|
||||
}
|
||||
|
||||
export const useDashboardStore = defineStore('dashboard', () => {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { api } from '@/services/api'
|
||||
import type { Category } from './categories'
|
||||
|
||||
export interface RecurringExpense {
|
||||
id: number
|
||||
@@ -9,6 +8,7 @@ export interface RecurringExpense {
|
||||
expected_amount: number
|
||||
day_of_month: number
|
||||
category_id: number | null
|
||||
type: 'income' | 'expense'
|
||||
active: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
@@ -17,6 +17,7 @@ export interface RecurringExpense {
|
||||
export interface RecurringStatus extends RecurringExpense {
|
||||
covered: boolean
|
||||
ignored: boolean
|
||||
late: boolean
|
||||
reason?: string
|
||||
}
|
||||
|
||||
@@ -74,8 +75,33 @@ export const useRecurringStore = defineStore('recurring', () => {
|
||||
await fetchMonthlyStatus(month)
|
||||
}
|
||||
|
||||
async function confirmIncome(id: number, month: string) {
|
||||
await api.post(`/recurring/${id}/confirm`, { month })
|
||||
await fetchMonthlyStatus(month)
|
||||
}
|
||||
|
||||
async function markLate(id: number, month: string) {
|
||||
await api.post(`/recurring/${id}/late`, { month })
|
||||
await fetchMonthlyStatus(month)
|
||||
}
|
||||
|
||||
const pendingCount = (month: string) =>
|
||||
monthlyStatus.value.filter((s) => !s.covered).length
|
||||
|
||||
return { items, monthlyStatus, loading, error, fetchAll, fetchMonthlyStatus, create, update, remove, ignore, unignore, pendingCount }
|
||||
return {
|
||||
items,
|
||||
monthlyStatus,
|
||||
loading,
|
||||
error,
|
||||
fetchAll,
|
||||
fetchMonthlyStatus,
|
||||
create,
|
||||
update,
|
||||
remove,
|
||||
ignore,
|
||||
unignore,
|
||||
confirmIncome,
|
||||
markLate,
|
||||
pendingCount,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -13,7 +13,7 @@ const typeLabels: Record<string, string> = {
|
||||
credit: 'Cartão de Crédito',
|
||||
}
|
||||
|
||||
const blank = (): AccountInput => ({ name: '', type: 'checking', initial_balance: 0 })
|
||||
const blank = (): AccountInput => ({ name: '', type: 'checking', initial_balance: 0, yield_type: 'none', closing_day: null, due_day: null })
|
||||
const form = ref(blank())
|
||||
const editId = ref<number | null>(null)
|
||||
const initialRaw = ref('')
|
||||
@@ -27,11 +27,21 @@ function startEdit(id: number) {
|
||||
const a = store.accounts.find((x) => x.id === id)
|
||||
if (!a) return
|
||||
editId.value = id
|
||||
form.value = { name: a.name, type: a.type, initial_balance: a.initial_balance }
|
||||
form.value = {
|
||||
name: a.name, type: a.type, initial_balance: a.initial_balance,
|
||||
yield_type: a.yield_type ?? 'none',
|
||||
closing_day: a.closing_day ?? null,
|
||||
due_day: a.due_day ?? null,
|
||||
}
|
||||
initialRaw.value = a.initial_balance.toLocaleString('pt-BR', { minimumFractionDigits: 2 })
|
||||
formError.value = null
|
||||
}
|
||||
|
||||
async function payBill(billId: number) {
|
||||
if (!confirm('Marcar fatura como PAGA?')) return
|
||||
await store.payBill(billId)
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
editId.value = null
|
||||
form.value = blank()
|
||||
@@ -85,6 +95,21 @@ function totalBalance() {
|
||||
<option value="investment">Investimento</option>
|
||||
<option value="credit">Cartão de Crédito</option>
|
||||
</select>
|
||||
<select v-model="form.yield_type" class="fc-select fc-acc-form__yield">
|
||||
<option value="none">Sem rendimento</option>
|
||||
<option value="cdi">CDI automático</option>
|
||||
<option value="variable">Renda variável</option>
|
||||
</select>
|
||||
<template v-if="form.type === 'credit'">
|
||||
<div class="fc-label fc-acc-form__day-wrap">
|
||||
Fechamento
|
||||
<input type="number" v-model.number="form.closing_day" min="1" max="28" class="fc-input fc-acc-form__day" />
|
||||
</div>
|
||||
<div class="fc-label fc-acc-form__day-wrap">
|
||||
Vencimento
|
||||
<input type="number" v-model.number="form.due_day" min="1" max="28" class="fc-input fc-acc-form__day" />
|
||||
</div>
|
||||
</template>
|
||||
<input
|
||||
v-model="initialRaw"
|
||||
placeholder="Saldo inicial (ex: 1.500,00)"
|
||||
@@ -120,7 +145,11 @@ function totalBalance() {
|
||||
:class="{ 'fc-acc-item--editing': editId === a.id }"
|
||||
>
|
||||
<div class="fc-acc-item__info">
|
||||
<div class="fc-acc-item__name-row">
|
||||
<span class="fc-body fc-acc-item__name">{{ a.name }}</span>
|
||||
<span v-if="a.yield_type === 'cdi'" class="fc-acc-badge fc-acc-badge--cdi fc-pixel">CDI</span>
|
||||
<span v-else-if="a.yield_type === 'variable'" class="fc-acc-badge fc-acc-badge--var fc-pixel">VAR</span>
|
||||
</div>
|
||||
<span class="fc-mono fc-acc-item__meta">
|
||||
{{ typeLabels[a.type] }} · inicial {{ fmt(a.initial_balance) }}
|
||||
</span>
|
||||
@@ -138,6 +167,32 @@ function totalBalance() {
|
||||
<li v-if="store.accounts.length === 0" class="fc-acc-empty fc-mono">— nenhuma conta cadastrada —</li>
|
||||
</ul>
|
||||
</NeonPanel>
|
||||
|
||||
<!-- 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'">
|
||||
<div class="fc-bill">
|
||||
<div class="fc-bill__row">
|
||||
<span class="fc-mono fc-bill__label">Período</span>
|
||||
<span class="fc-mono fc-bill__val">{{ a.current_bill!.period_start }} → {{ a.current_bill!.period_end }}</span>
|
||||
</div>
|
||||
<div class="fc-bill__row">
|
||||
<span class="fc-mono fc-bill__label">Vencimento</span>
|
||||
<span class="fc-mono fc-bill__val fc-blink" style="color:var(--fc-red)">{{ a.current_bill!.due_date }}</span>
|
||||
</div>
|
||||
<div class="fc-bill__row">
|
||||
<span class="fc-pixel fc-bill__label" style="font-size:8px">TOTAL FATURA</span>
|
||||
<span class="fc-mono fc-bill__total" style="color:var(--fc-red); font-size:20px; font-weight:700">
|
||||
{{ fmt(a.current_bill!.total) }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="!a.current_bill!.paid" class="fc-bill__actions">
|
||||
<button class="fc-btn fc-btn--primary" @click="payBill(a.current_bill!.id)">MARCAR PAGA</button>
|
||||
</div>
|
||||
<div v-else class="fc-bill__paid fc-pixel">FATURA PAGA ✓</div>
|
||||
</div>
|
||||
</NeonPanel>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -165,8 +220,9 @@ function totalBalance() {
|
||||
}
|
||||
|
||||
.fc-acc-form__name { flex: 1; min-width: 160px; }
|
||||
.fc-acc-form__type { width: 160px; }
|
||||
.fc-acc-form__balance { width: 160px; }
|
||||
.fc-acc-form__type { width: 150px; }
|
||||
.fc-acc-form__yield { width: 160px; }
|
||||
.fc-acc-form__balance { width: 150px; }
|
||||
|
||||
.fc-acc-form__error {
|
||||
color: var(--fc-red);
|
||||
@@ -237,9 +293,27 @@ function totalBalance() {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.fc-acc-item__name-row { display: flex; align-items: center; gap: 8px; }
|
||||
.fc-acc-item__name { font-size: 14px; font-weight: 500; }
|
||||
.fc-acc-item__meta { font-size: 11px; color: var(--fc-text-dim); }
|
||||
|
||||
.fc-acc-badge {
|
||||
font-size: 7px; padding: 2px 5px; border-radius: 2px; letter-spacing: .05em;
|
||||
}
|
||||
.fc-acc-badge--cdi { background: rgba(34,197,94,.2); color: var(--fc-green); border: 1px solid var(--fc-green); }
|
||||
.fc-acc-badge--var { background: rgba(251,191,36,.2); color: var(--fc-gold); border: 1px solid var(--fc-gold); }
|
||||
|
||||
.fc-acc-form__day-wrap { width: 90px; flex-shrink: 0; }
|
||||
.fc-acc-form__day { width: 100%; }
|
||||
|
||||
/* Credit bill widget */
|
||||
.fc-bill { display: flex; flex-direction: column; gap: var(--fc-space-3); }
|
||||
.fc-bill__row { display: flex; justify-content: space-between; align-items: baseline; gap: var(--fc-space-2); }
|
||||
.fc-bill__label { font-size: 10px; color: var(--fc-text-dim); }
|
||||
.fc-bill__val { font-size: 12px; }
|
||||
.fc-bill__actions { margin-top: var(--fc-space-2); }
|
||||
.fc-bill__paid { font-size: 8px; color: var(--fc-green); margin-top: var(--fc-space-2); }
|
||||
|
||||
.fc-acc-item__right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useDashboardStore } from '@/stores/dashboard'
|
||||
import { useGameStore } from '@/stores/game'
|
||||
import { useRecurringStore } from '@/stores/recurring'
|
||||
import { useAccountsStore } from '@/stores/accounts'
|
||||
import NeonPanel from '@/components/NeonPanel.vue'
|
||||
import XPBar from '@/components/XPBar.vue'
|
||||
import CharacterSprite from '@/components/CharacterSprite.vue'
|
||||
@@ -9,6 +11,8 @@ import MonthSwitcher from '@/components/MonthSwitcher.vue'
|
||||
|
||||
const dash = useDashboardStore()
|
||||
const game = useGameStore()
|
||||
const recurring = useRecurringStore()
|
||||
const accountsStore = useAccountsStore()
|
||||
const currentMonth = ref(new Date().toISOString().slice(0, 7))
|
||||
|
||||
onMounted(() => dash.fetch(currentMonth.value))
|
||||
@@ -51,6 +55,29 @@ const xpPct = computed(() => {
|
||||
})
|
||||
const savingsPct = computed(() => dash.data?.savings_pct ?? 0)
|
||||
const savingsOk = computed(() => savingsPct.value >= 40)
|
||||
|
||||
const today = new Date()
|
||||
const isCurrentMonth = computed(() => currentMonth.value === today.toISOString().slice(0, 7))
|
||||
const dayOfMonth = today.getDate()
|
||||
const pendingIncome = computed(() => dash.data?.pending_income_recurrings ?? [])
|
||||
const showIncomeWidget = computed(() => isCurrentMonth.value && pendingIncome.value.length > 0)
|
||||
const showIncomeConfirmButtons = computed(() => dayOfMonth <= 5)
|
||||
|
||||
const currentBills = computed(() => dash.data?.current_bills?.filter(b => !b.paid) ?? [])
|
||||
|
||||
async function payBill(billId: number) {
|
||||
await accountsStore.payBill(billId)
|
||||
await dash.fetch(currentMonth.value)
|
||||
}
|
||||
|
||||
async function confirmIncome(id: number) {
|
||||
await recurring.confirmIncome(id, currentMonth.value)
|
||||
await dash.fetch(currentMonth.value)
|
||||
}
|
||||
async function markLate(id: number) {
|
||||
await recurring.markLate(id, currentMonth.value)
|
||||
await dash.fetch(currentMonth.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -64,7 +91,7 @@ const savingsOk = computed(() => savingsPct.value >= 40)
|
||||
<div v-if="dash.loading" class="dash-loading fc-pixel">CARREGANDO...</div>
|
||||
|
||||
<template v-else-if="dash.data">
|
||||
<!-- Alert banner -->
|
||||
<!-- Alert banner — despesas recorrentes ausentes -->
|
||||
<div v-if="dash.data.pending_recurring > 0" class="dash-alert">
|
||||
<span class="fc-blink" style="color:var(--fc-red)">▲</span>
|
||||
<span class="fc-pixel" style="font-size:8px">
|
||||
@@ -72,6 +99,37 @@ const savingsOk = computed(() => savingsPct.value >= 40)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Income confirmation widget (day 1–5 of current month) -->
|
||||
<template v-if="showIncomeWidget">
|
||||
<NeonPanel v-if="showIncomeConfirmButtons" title="CONFIRMAR RECEITAS" :variant="'income'" class="dash-income-panel">
|
||||
<div class="income-list">
|
||||
<div v-for="item in pendingIncome" :key="item.id" class="income-item">
|
||||
<div class="income-item__info">
|
||||
<span class="fc-body income-item__name">{{ item.name }}</span>
|
||||
<span class="fc-mono income-item__amount" style="color:var(--fc-green)">
|
||||
{{ fmt(item.expected_amount) }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="item.late" class="income-item__actions">
|
||||
<span class="fc-chip income-chip--late fc-pixel">ATRASOU</span>
|
||||
</div>
|
||||
<div v-else class="income-item__actions">
|
||||
<button class="fc-btn fc-btn--sm fc-btn--primary" @click="confirmIncome(item.id)">CONFIRMAR</button>
|
||||
<button class="fc-btn fc-btn--sm fc-btn--ghost" @click="markLate(item.id)">ATRASOU</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</NeonPanel>
|
||||
|
||||
<!-- After day 5: salary pending badge -->
|
||||
<div v-else class="dash-alert dash-alert--salary">
|
||||
<span class="fc-blink" style="color:var(--fc-gold)">▲</span>
|
||||
<span class="fc-pixel" style="font-size:8px">
|
||||
SALÁRIO PENDENTE — {{ pendingIncome.map(i => i.name).join(', ') }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="dash-grid">
|
||||
<!-- Left column -->
|
||||
<div class="dash-col-main">
|
||||
@@ -200,6 +258,21 @@ const savingsOk = computed(() => savingsPct.value >= 40)
|
||||
</div>
|
||||
</div>
|
||||
</NeonPanel>
|
||||
<!-- Credit card bills -->
|
||||
<NeonPanel v-if="currentBills.length > 0" title="FATURAS ABERTAS" :variant="'danger'">
|
||||
<div class="bill-list">
|
||||
<div v-for="bill in currentBills" :key="bill.id" class="bill-item">
|
||||
<div class="bill-item__info">
|
||||
<span class="fc-body bill-item__name">{{ bill.account_name }}</span>
|
||||
<span class="fc-mono bill-item__due" style="color:var(--fc-red)">vence {{ bill.due_date }}</span>
|
||||
</div>
|
||||
<div class="bill-item__right">
|
||||
<span class="fc-mono bill-item__total" style="color:var(--fc-red);font-weight:700">{{ fmt(bill.total) }}</span>
|
||||
<button class="fc-btn fc-btn--sm fc-btn--ghost" @click="payBill(bill.id)">PAGAR</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</NeonPanel>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -289,6 +362,34 @@ const savingsOk = computed(() => savingsPct.value >= 40)
|
||||
|
||||
.empty { font-size: 8px; color: var(--fc-text-dim); padding: 8px 0; }
|
||||
|
||||
/* Bill widget */
|
||||
.bill-list { display: flex; flex-direction: column; gap: 10px; }
|
||||
.bill-item { display: flex; justify-content: space-between; align-items: center; gap: var(--fc-space-2); flex-wrap: wrap; }
|
||||
.bill-item__info { display: flex; flex-direction: column; gap: 3px; }
|
||||
.bill-item__name { font-size: 13px; font-weight: 500; }
|
||||
.bill-item__due { font-size: 10px; }
|
||||
.bill-item__right { display: flex; align-items: center; gap: var(--fc-space-2); }
|
||||
.bill-item__total { font-size: 15px; }
|
||||
|
||||
/* Income widget */
|
||||
.dash-income-panel { margin-bottom: 0; }
|
||||
.dash-alert--salary { background: rgba(255,180,0,.08); border-color: var(--fc-gold); }
|
||||
.income-list { display: flex; flex-direction: column; gap: 10px; }
|
||||
.income-item {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
gap: var(--fc-space-3); padding: 10px var(--fc-space-3);
|
||||
border: 1px solid var(--fc-panel-edge); border-radius: var(--fc-radius);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.income-item__info { display: flex; align-items: center; gap: var(--fc-space-3); }
|
||||
.income-item__name { font-size: 14px; font-weight: 500; }
|
||||
.income-item__amount { font-size: 13px; font-weight: 700; }
|
||||
.income-item__actions { display: flex; gap: var(--fc-space-1); align-items: center; }
|
||||
.income-chip--late {
|
||||
font-size: 7px; padding: 3px 6px; border-radius: 2px;
|
||||
background: var(--fc-accent); color: var(--fc-bg);
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.dash-page { padding: 14px 12px 80px; }
|
||||
.tx-row { grid-template-columns: 70px 1fr 80px; }
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRecurringStore } from '@/stores/recurring'
|
||||
import { useCategoriesStore } from '@/stores/categories'
|
||||
import NeonPanel from '@/components/NeonPanel.vue'
|
||||
|
||||
const store = useRecurringStore()
|
||||
const catStore = useCategoriesStore()
|
||||
const currentMonth = ref(new Date().toISOString().slice(0, 7))
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([store.fetchAll(), catStore.fetchAll()])
|
||||
await store.fetchMonthlyStatus(currentMonth.value)
|
||||
})
|
||||
|
||||
const blank = (): Omit<typeof store.items[0], 'id' | 'active' | 'created_at' | 'updated_at'> => ({
|
||||
name: '',
|
||||
expected_amount: 0,
|
||||
day_of_month: 1,
|
||||
category_id: null,
|
||||
type: 'expense',
|
||||
})
|
||||
|
||||
const form = ref(blank())
|
||||
const editId = ref<number | null>(null)
|
||||
const amountRaw = ref('')
|
||||
const formError = ref<string | null>(null)
|
||||
|
||||
function parseAmount(s: string) {
|
||||
return parseFloat(s.replace(/\./g, '').replace(',', '.')) || 0
|
||||
}
|
||||
|
||||
function startEdit(id: number) {
|
||||
const item = store.items.find((x) => x.id === id)
|
||||
if (!item) return
|
||||
editId.value = id
|
||||
form.value = { name: item.name, expected_amount: item.expected_amount, day_of_month: item.day_of_month, category_id: item.category_id, type: item.type }
|
||||
amountRaw.value = item.expected_amount.toLocaleString('pt-BR', { minimumFractionDigits: 2 })
|
||||
formError.value = null
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
editId.value = null
|
||||
form.value = blank()
|
||||
amountRaw.value = ''
|
||||
formError.value = null
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
formError.value = null
|
||||
form.value.expected_amount = parseAmount(amountRaw.value)
|
||||
try {
|
||||
if (editId.value !== null) {
|
||||
await store.update(editId.value, form.value)
|
||||
cancelEdit()
|
||||
} else {
|
||||
await store.create(form.value)
|
||||
form.value = blank()
|
||||
amountRaw.value = ''
|
||||
}
|
||||
await store.fetchMonthlyStatus(currentMonth.value)
|
||||
} catch (e: any) {
|
||||
formError.value = e.message
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(id: number, name: string) {
|
||||
if (!confirm(`Excluir recorrência "${name}"?`)) return
|
||||
await store.remove(id)
|
||||
}
|
||||
|
||||
async function confirmIncome(id: number) {
|
||||
await store.confirmIncome(id, currentMonth.value)
|
||||
}
|
||||
|
||||
async function markLate(id: number) {
|
||||
await store.markLate(id, currentMonth.value)
|
||||
}
|
||||
|
||||
function catName(id: number | null) {
|
||||
if (!id) return '—'
|
||||
return catStore.categories.find((c) => c.id === id)?.name ?? '—'
|
||||
}
|
||||
|
||||
function fmt(v: number) {
|
||||
return v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })
|
||||
}
|
||||
|
||||
const incomeItems = computed(() => store.items.filter((x) => x.type === 'income'))
|
||||
const expenseItems = computed(() => store.items.filter((x) => x.type === 'expense'))
|
||||
|
||||
function getStatus(id: number) {
|
||||
return store.monthlyStatus.find((s) => s.id === id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fc-view">
|
||||
<span class="fc-pixel fc-view__title">:: RECORRÊNCIAS</span>
|
||||
|
||||
<!-- Form -->
|
||||
<NeonPanel :title="editId !== null ? 'EDITAR RECORRÊNCIA' : 'NOVA RECORRÊNCIA'">
|
||||
<form class="rec-form" @submit.prevent="submit">
|
||||
<div class="rec-form__row">
|
||||
<input v-model="form.name" placeholder="Nome (ex: Salário, Netflix)" required class="fc-input rec-form__name" />
|
||||
<input v-model="amountRaw" placeholder="3.200,00" required class="fc-input rec-form__amount" />
|
||||
<div class="fc-label rec-form__day-wrap">
|
||||
Dia
|
||||
<input type="number" v-model.number="form.day_of_month" min="1" max="31" class="fc-input rec-form__day" />
|
||||
</div>
|
||||
<select v-model="form.category_id" class="fc-select rec-form__cat">
|
||||
<option :value="null">Sem categoria</option>
|
||||
<option v-for="c in catStore.categories" :key="c.id" :value="c.id">{{ c.name }}</option>
|
||||
</select>
|
||||
<select v-model="form.type" class="fc-select rec-form__type">
|
||||
<option value="expense">DESPESA</option>
|
||||
<option value="income">RECEITA</option>
|
||||
</select>
|
||||
</div>
|
||||
<p v-if="formError" class="rec-form__error fc-mono">{{ formError }}</p>
|
||||
<div class="rec-form__actions">
|
||||
<button type="submit" class="fc-btn fc-btn--primary">{{ editId !== null ? 'SALVAR' : 'ADICIONAR' }}</button>
|
||||
<button v-if="editId !== null" type="button" class="fc-btn fc-btn--ghost" @click="cancelEdit">CANCELAR</button>
|
||||
</div>
|
||||
</form>
|
||||
</NeonPanel>
|
||||
|
||||
<!-- RECEITAS -->
|
||||
<NeonPanel title="RECEITAS">
|
||||
<p v-if="store.loading" class="rec-empty fc-mono">carregando...</p>
|
||||
<ul v-else class="rec-list">
|
||||
<li v-for="item in incomeItems" :key="item.id" class="rec-item" :class="{ 'rec-item--editing': editId === item.id }">
|
||||
<div class="rec-item__info">
|
||||
<span class="fc-body rec-item__name">{{ item.name }}</span>
|
||||
<span class="fc-mono rec-item__meta">
|
||||
Todo dia {{ item.day_of_month }} · {{ fmt(item.expected_amount) }} · {{ catName(item.category_id) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="rec-item__right">
|
||||
<!-- Monthly status chip -->
|
||||
<template v-if="getStatus(item.id)">
|
||||
<span v-if="getStatus(item.id)!.covered" class="fc-chip chip--ok fc-pixel">CONFIRMADO</span>
|
||||
<template v-else-if="getStatus(item.id)!.late">
|
||||
<span class="fc-chip chip--late fc-pixel">ATRASOU</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button class="fc-btn fc-btn--sm fc-btn--primary" @click="confirmIncome(item.id)">CONFIRMAR</button>
|
||||
<button class="fc-btn fc-btn--sm fc-btn--ghost" @click="markLate(item.id)">ATRASOU</button>
|
||||
</template>
|
||||
</template>
|
||||
<div class="rec-item__actions">
|
||||
<button class="fc-btn fc-btn--sm fc-btn--ghost" @click="startEdit(item.id)">EDITAR</button>
|
||||
<button class="fc-btn fc-btn--sm fc-btn--danger" @click="remove(item.id, item.name)">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<li v-if="incomeItems.length === 0" class="rec-empty fc-mono">— nenhuma receita recorrente cadastrada —</li>
|
||||
</ul>
|
||||
</NeonPanel>
|
||||
|
||||
<!-- DESPESAS -->
|
||||
<NeonPanel title="DESPESAS">
|
||||
<p v-if="store.loading" class="rec-empty fc-mono">carregando...</p>
|
||||
<ul v-else class="rec-list">
|
||||
<li v-for="item in expenseItems" :key="item.id" class="rec-item" :class="{ 'rec-item--editing': editId === item.id }">
|
||||
<div class="rec-item__info">
|
||||
<span class="fc-body rec-item__name">{{ item.name }}</span>
|
||||
<span class="fc-mono rec-item__meta">
|
||||
Todo dia {{ item.day_of_month }} · {{ fmt(item.expected_amount) }} · {{ catName(item.category_id) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="rec-item__right">
|
||||
<template v-if="getStatus(item.id)">
|
||||
<span v-if="getStatus(item.id)!.covered" class="fc-chip chip--ok fc-pixel">OK</span>
|
||||
<span v-else class="fc-chip chip--pending fc-pixel">PENDENTE</span>
|
||||
</template>
|
||||
<div class="rec-item__actions">
|
||||
<button class="fc-btn fc-btn--sm fc-btn--ghost" @click="startEdit(item.id)">EDITAR</button>
|
||||
<button class="fc-btn fc-btn--sm fc-btn--danger" @click="remove(item.id, item.name)">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<li v-if="expenseItems.length === 0" class="rec-empty fc-mono">— nenhuma despesa recorrente cadastrada —</li>
|
||||
</ul>
|
||||
</NeonPanel>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.fc-view {
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
padding: var(--fc-space-4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--fc-space-4);
|
||||
padding-bottom: 96px;
|
||||
}
|
||||
|
||||
.fc-view__title {
|
||||
font-size: 11px;
|
||||
color: var(--fc-accent-2);
|
||||
}
|
||||
|
||||
.rec-form__row {
|
||||
display: flex;
|
||||
gap: var(--fc-space-2);
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.rec-form__name { flex: 1; min-width: 150px; }
|
||||
.rec-form__amount { width: 110px; }
|
||||
.rec-form__day-wrap { width: 70px; flex-shrink: 0; }
|
||||
.rec-form__day { width: 100%; }
|
||||
.rec-form__cat { width: 150px; }
|
||||
.rec-form__type { width: 110px; }
|
||||
|
||||
.rec-form__error {
|
||||
color: var(--fc-red);
|
||||
font-size: 11px;
|
||||
margin-top: var(--fc-space-2);
|
||||
}
|
||||
|
||||
.rec-form__actions {
|
||||
display: flex;
|
||||
gap: var(--fc-space-2);
|
||||
margin-top: var(--fc-space-3);
|
||||
}
|
||||
|
||||
.rec-empty {
|
||||
font-size: 11px;
|
||||
color: var(--fc-text-dim);
|
||||
text-align: center;
|
||||
padding: var(--fc-space-4) 0;
|
||||
}
|
||||
|
||||
.rec-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--fc-space-2);
|
||||
}
|
||||
|
||||
.rec-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--fc-space-3);
|
||||
padding: 12px var(--fc-space-3);
|
||||
border: 1px solid var(--fc-panel-edge);
|
||||
border-radius: var(--fc-radius);
|
||||
flex-wrap: wrap;
|
||||
transition: border-color .15s;
|
||||
}
|
||||
|
||||
.rec-item--editing {
|
||||
border-color: var(--fc-accent-3);
|
||||
box-shadow: 0 0 8px rgba(168,85,247,.3);
|
||||
}
|
||||
|
||||
.rec-item__info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.rec-item__name { font-size: 14px; font-weight: 500; }
|
||||
.rec-item__meta { font-size: 11px; color: var(--fc-text-dim); }
|
||||
|
||||
.rec-item__right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--fc-space-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.rec-item__actions { display: flex; gap: var(--fc-space-1); }
|
||||
|
||||
.fc-chip {
|
||||
font-size: 7px;
|
||||
padding: 3px 6px;
|
||||
border-radius: 2px;
|
||||
letter-spacing: .05em;
|
||||
}
|
||||
|
||||
.chip--ok { background: rgba(34,197,94,.2); color: var(--fc-green); border: 1px solid var(--fc-green); }
|
||||
.chip--late { background: rgba(255,180,0,.2); color: var(--fc-gold); border: 1px solid var(--fc-gold); }
|
||||
.chip--pending { background: rgba(255,59,107,.1); color: var(--fc-red); border: 1px solid var(--fc-red); }
|
||||
</style>
|
||||
Reference in New Issue
Block a user