feat: merge receitas recorrentes, CDI yield e cartão de crédito (#36, #35, #37)

This commit is contained in:
2026-05-27 15:17:45 -03:00
30 changed files with 1481 additions and 110 deletions
+12 -2
View File
@@ -70,11 +70,15 @@ func main() {
recurringHandler := handler.NewRecurringHandler(recurringSvc) recurringHandler := handler.NewRecurringHandler(recurringSvc)
accountRepo := repository.NewAccountRepository(pool) 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) accountHandler := handler.NewAccountHandler(accountSvc)
creditBillHandler := handler.NewCreditBillHandler(creditBillSvc)
dashboardRepo := repository.NewDashboardRepository(pool) dashboardRepo := repository.NewDashboardRepository(pool)
dashboardSvc := service.NewDashboardService(dashboardRepo, recurringSvc, accountRepo) dashboardSvc := service.NewDashboardService(dashboardRepo, recurringSvc, accountRepo, creditBillSvc)
dashboardHandler := handler.NewDashboardHandler(dashboardSvc) dashboardHandler := handler.NewDashboardHandler(dashboardSvc)
r.Get("/health", handler.Health) r.Get("/health", handler.Health)
@@ -105,11 +109,17 @@ func main() {
r.Get("/recurring/status", recurringHandler.MonthlyStatus) r.Get("/recurring/status", recurringHandler.MonthlyStatus)
r.Post("/recurring/{id}/ignore", recurringHandler.Ignore) r.Post("/recurring/{id}/ignore", recurringHandler.Ignore)
r.Delete("/recurring/{id}/ignore", recurringHandler.Unignore) 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.Get("/accounts", accountHandler.List)
r.Post("/accounts", accountHandler.Create) r.Post("/accounts", accountHandler.Create)
r.Put("/accounts/{id}", accountHandler.Update) r.Put("/accounts/{id}", accountHandler.Update)
r.Delete("/accounts/{id}", accountHandler.Delete) 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) r.Get("/dashboard", dashboardHandler.Get)
+86
View File
@@ -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)
}
+46
View File
@@ -128,3 +128,49 @@ func (h *RecurringHandler) Unignore(w http.ResponseWriter, r *http.Request) {
} }
w.WriteHeader(http.StatusNoContent) 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)
);
+8
View File
@@ -6,6 +6,11 @@ type Account struct {
Type string `json:"type"` Type string `json:"type"`
InitialBalance float64 `json:"initial_balance"` InitialBalance float64 `json:"initial_balance"`
Balance float64 `json:"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"` CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"` UpdatedAt string `json:"updated_at"`
} }
@@ -14,4 +19,7 @@ type AccountInput struct {
Name string `json:"name"` Name string `json:"name"`
Type string `json:"type"` Type string `json:"type"`
InitialBalance float64 `json:"initial_balance"` 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"`
} }
+22
View File
@@ -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"`
}
+11 -9
View File
@@ -24,13 +24,15 @@ type RecentTransaction struct {
} }
type DashboardData struct { type DashboardData struct {
Month string `json:"month"` Month string `json:"month"`
TotalIncome float64 `json:"total_income"` TotalIncome float64 `json:"total_income"`
TotalExpenses float64 `json:"total_expenses"` TotalExpenses float64 `json:"total_expenses"`
SavingsPct float64 `json:"savings_pct"` SavingsPct float64 `json:"savings_pct"`
TotalPatrimony float64 `json:"total_patrimony"` TotalPatrimony float64 `json:"total_patrimony"`
ByCategory []CategoryTotal `json:"by_category"` ByCategory []CategoryTotal `json:"by_category"`
MonthlyEvolution []MonthEvolution `json:"monthly_evolution"` MonthlyEvolution []MonthEvolution `json:"monthly_evolution"`
RecentTransactions []RecentTransaction `json:"recent_transactions"` RecentTransactions []RecentTransaction `json:"recent_transactions"`
PendingRecurring int `json:"pending_recurring"` PendingRecurring int `json:"pending_recurring"`
PendingIncomeRecurrings []PendingIncome `json:"pending_income_recurrings"`
CurrentBills []CreditBill `json:"current_bills"`
} }
+15 -3
View File
@@ -8,6 +8,7 @@ type RecurringExpense struct {
ExpectedAmount float64 `json:"expected_amount"` ExpectedAmount float64 `json:"expected_amount"`
DayOfMonth int `json:"day_of_month"` DayOfMonth int `json:"day_of_month"`
CategoryID *int `json:"category_id"` CategoryID *int `json:"category_id"`
Type string `json:"type"` // "income" | "expense"
Active bool `json:"active"` Active bool `json:"active"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
@@ -18,12 +19,23 @@ type RecurringInput struct {
ExpectedAmount float64 `json:"expected_amount"` ExpectedAmount float64 `json:"expected_amount"`
DayOfMonth int `json:"day_of_month"` DayOfMonth int `json:"day_of_month"`
CategoryID *int `json:"category_id"` 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 { type RecurringStatus struct {
RecurringExpense RecurringExpense
Covered bool `json:"covered"` // has a matching transaction Covered bool `json:"covered"` // has a matching transaction or was ignored
Ignored bool `json:"ignored"` // user explicitly ignored this month 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"` 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"`
}
+40 -13
View File
@@ -8,6 +8,17 @@ import (
"financeiro-carvalho/internal/model" "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 { type AccountRepository struct {
pool *pgxpool.Pool pool *pgxpool.Pool
} }
@@ -20,6 +31,8 @@ func (r *AccountRepository) List(ctx context.Context) ([]model.Account, error) {
rows, err := r.pool.Query(ctx, ` rows, err := r.pool.Query(ctx, `
SELECT SELECT
a.id, a.name, a.type, a.initial_balance, 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.created_at::text, a.updated_at::text,
a.initial_balance a.initial_balance
+ COALESCE(SUM(CASE WHEN t.type = 'income' THEN t.amount ELSE 0 END), 0) + 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 var out []model.Account
for rows.Next() { for rows.Next() {
var a model.Account 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 return nil, err
} }
out = append(out, a) out = append(out, a)
@@ -51,6 +64,8 @@ func (r *AccountRepository) GetByID(ctx context.Context, id int) (*model.Account
err := r.pool.QueryRow(ctx, ` err := r.pool.QueryRow(ctx, `
SELECT SELECT
a.id, a.name, a.type, a.initial_balance, 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.created_at::text, a.updated_at::text,
a.initial_balance a.initial_balance
+ COALESCE(SUM(CASE WHEN t.type = 'income' THEN t.amount ELSE 0 END), 0) + 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 LEFT JOIN transactions t ON t.account_id = a.id
WHERE a.id = $1 WHERE a.id = $1
GROUP BY a.id 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 { if err != nil {
return nil, err 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) { func (r *AccountRepository) Create(ctx context.Context, in model.AccountInput) (*model.Account, error) {
yt := in.YieldType
if yt == "" {
yt = "none"
}
var a model.Account var a model.Account
err := r.pool.QueryRow(ctx, ` err := r.pool.QueryRow(ctx, `
INSERT INTO accounts (name, type, initial_balance) INSERT INTO accounts (name, type, initial_balance, yield_type, closing_day, due_day)
VALUES ($1, $2, $3) VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, name, type, initial_balance, created_at::text, updated_at::text 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). `, in.Name, in.Type, in.InitialBalance, yt, in.ClosingDay, in.DueDay).
Scan(&a.ID, &a.Name, &a.Type, &a.InitialBalance, &a.CreatedAt, &a.UpdatedAt) Scan(&a.ID, &a.Name, &a.Type, &a.InitialBalance, &a.YieldType, &a.LastYieldDate, &a.ClosingDay, &a.DueDay, &a.CreatedAt, &a.UpdatedAt)
if err != nil { if err != nil {
return nil, err 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) { 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, ` row := r.pool.QueryRow(ctx, `
UPDATE accounts SET name=$1, type=$2, initial_balance=$3, updated_at=NOW() UPDATE accounts SET name=$1, type=$2, initial_balance=$3, yield_type=$4, closing_day=$5, due_day=$6, updated_at=NOW()
WHERE id=$4 WHERE id=$7
RETURNING id, name, type, initial_balance, created_at::text, updated_at::text 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, id) `, in.Name, in.Type, in.InitialBalance, yt, in.ClosingDay, in.DueDay, id)
var a model.Account 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 return nil, err
} }
// recalculate balance
full, err := r.GetByID(ctx, a.ID) full, err := r.GetByID(ctx, a.ID)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -119,3 +141,8 @@ func (r *AccountRepository) TotalPatrimony(ctx context.Context) (float64, error)
`).Scan(&total) `).Scan(&total)
return total, err 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
}
+103
View File
@@ -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
}
+50 -14
View File
@@ -19,6 +19,9 @@ type RecurringRepository interface {
IsIgnored(ctx context.Context, id int, month string) (bool, string, error) IsIgnored(ctx context.Context, id int, month string) (bool, string, error)
Ignore(ctx context.Context, id int, month, reason string) error Ignore(ctx context.Context, id int, month, reason string) error
Unignore(ctx context.Context, id int, month 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 } 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) { func (r *recurringRepo) List(ctx context.Context) ([]model.RecurringExpense, error) {
rows, err := r.db.Query(ctx, ` 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`) FROM recurring_expenses ORDER BY name ASC`)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -38,7 +41,7 @@ func (r *recurringRepo) List(ctx context.Context) ([]model.RecurringExpense, err
var out []model.RecurringExpense var out []model.RecurringExpense
for rows.Next() { for rows.Next() {
var re model.RecurringExpense 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 return nil, err
} }
out = append(out, re) 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) { func (r *recurringRepo) GetByID(ctx context.Context, id int) (*model.RecurringExpense, error) {
var re model.RecurringExpense var re model.RecurringExpense
err := r.db.QueryRow(ctx, ` 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). 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) { if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound 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) { func (r *recurringRepo) Create(ctx context.Context, in model.RecurringInput) (*model.RecurringExpense, error) {
t := in.Type
if t == "" {
t = "expense"
}
var re model.RecurringExpense var re model.RecurringExpense
err := r.db.QueryRow(ctx, ` err := r.db.QueryRow(ctx, `
INSERT INTO recurring_expenses (name, expected_amount, day_of_month, category_id) INSERT INTO recurring_expenses (name, expected_amount, day_of_month, category_id, type)
VALUES ($1, $2, $3, $4) VALUES ($1, $2, $3, $4, $5)
RETURNING id, name, expected_amount, day_of_month, category_id, active, created_at, updated_at`, RETURNING id, name, expected_amount, day_of_month, category_id, type, active, created_at, updated_at`,
in.Name, in.ExpectedAmount, in.DayOfMonth, in.CategoryID). in.Name, in.ExpectedAmount, in.DayOfMonth, in.CategoryID, t).
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)
return &re, err return &re, err
} }
func (r *recurringRepo) Update(ctx context.Context, id int, in model.RecurringInput) (*model.RecurringExpense, error) { 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 var re model.RecurringExpense
err := r.db.QueryRow(ctx, ` err := r.db.QueryRow(ctx, `
UPDATE recurring_expenses UPDATE recurring_expenses
SET name = $1, expected_amount = $2, day_of_month = $3, category_id = $4, updated_at = NOW() SET name = $1, expected_amount = $2, day_of_month = $3, category_id = $4, type = $5, updated_at = NOW()
WHERE id = $5 WHERE id = $6
RETURNING id, name, expected_amount, day_of_month, category_id, active, created_at, updated_at`, RETURNING id, name, expected_amount, day_of_month, category_id, type, active, created_at, updated_at`,
in.Name, in.ExpectedAmount, in.DayOfMonth, in.CategoryID, id). in.Name, in.ExpectedAmount, in.DayOfMonth, in.CategoryID, t, 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) { if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound 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) _, err := r.db.Exec(ctx, `DELETE FROM recurring_ignores WHERE recurring_id = $1 AND month = $2`, id, month)
return err 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) Create(ctx context.Context, t model.Transaction) (*model.Transaction, error)
Update(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 Delete(ctx context.Context, id int) error
// HasMatchingTransaction checks if a category has an expense in the given month. // 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) (bool, error) HasMatchingTransaction(ctx context.Context, categoryID *int, month string, amount float64, txType string) (bool, error)
} }
type manualTxRepo struct{ db *pgxpool.Pool } type manualTxRepo struct{ db *pgxpool.Pool }
@@ -103,20 +103,18 @@ func (r *manualTxRepo) Delete(ctx context.Context, id int) error {
return nil return nil
} }
func (r *manualTxRepo) HasMatchingTransaction(ctx context.Context, categoryID *int, month string, amount float64) (bool, error) { func (r *manualTxRepo) HasMatchingTransaction(ctx context.Context, categoryID *int, month string, amount float64, txType string) (bool, error) {
var count int if categoryID == nil {
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 {
// No category set — cannot auto-match, always report as uncovered // No category set — cannot auto-match, always report as uncovered
return false, nil 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 return count > 0, err
} }
+43 -4
View File
@@ -5,6 +5,7 @@ import (
"errors" "errors"
"financeiro-carvalho/internal/model" "financeiro-carvalho/internal/model"
"financeiro-carvalho/internal/repository"
) )
var ( var (
@@ -26,11 +27,13 @@ type AccountRepo interface {
} }
type AccountService struct { type AccountService struct {
repo AccountRepo repo repository.AccountRepoWithYield
cdiSvc *CDIYieldService
billSvc *CreditBillService
} }
func NewAccountService(repo AccountRepo) *AccountService { func NewAccountService(repo repository.AccountRepoWithYield, cdiSvc *CDIYieldService, billSvc *CreditBillService) *AccountService {
return &AccountService{repo: repo} return &AccountService{repo: repo, cdiSvc: cdiSvc, billSvc: billSvc}
} }
func (s *AccountService) validate(in model.AccountInput) error { func (s *AccountService) validate(in model.AccountInput) error {
@@ -43,8 +46,44 @@ func (s *AccountService) validate(in model.AccountInput) error {
return nil 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) { 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) { func (s *AccountService) Create(ctx context.Context, in model.AccountInput) (*model.Account, error) {
+26 -5
View File
@@ -37,11 +37,32 @@ func (m *mockAccountRepo) Update(_ context.Context, id int, in model.AccountInpu
} }
return nil, nil return nil, nil
} }
func (m *mockAccountRepo) Delete(_ context.Context, _ int) error { return nil } func (m *mockAccountRepo) Delete(_ context.Context, _ int) error { return nil }
func (m *mockAccountRepo) TotalPatrimony(_ context.Context) (float64, error) { return 0, 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) { func TestCreateAccount_EmptyName(t *testing.T) {
svc := service.NewAccountService(&mockAccountRepo{}) svc := newAccountSvc()
_, err := svc.Create(context.Background(), model.AccountInput{Name: "", Type: "checking", InitialBalance: 0}) _, err := svc.Create(context.Background(), model.AccountInput{Name: "", Type: "checking", InitialBalance: 0})
if err != service.ErrAccountEmptyName { if err != service.ErrAccountEmptyName {
t.Fatalf("expected ErrAccountEmptyName, got %v", err) t.Fatalf("expected ErrAccountEmptyName, got %v", err)
@@ -49,7 +70,7 @@ func TestCreateAccount_EmptyName(t *testing.T) {
} }
func TestCreateAccount_InvalidType(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"}) _, err := svc.Create(context.Background(), model.AccountInput{Name: "Nubank", Type: "bitcoin"})
if err != service.ErrAccountInvalidType { if err != service.ErrAccountInvalidType {
t.Fatalf("expected ErrAccountInvalidType, got %v", err) t.Fatalf("expected ErrAccountInvalidType, got %v", err)
@@ -57,7 +78,7 @@ func TestCreateAccount_InvalidType(t *testing.T) {
} }
func TestCreateAccount_OK(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}) a, err := svc.Create(context.Background(), model.AccountInput{Name: "Nubank", Type: "checking", InitialBalance: 1000})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
+139
View File
@@ -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
}
+95
View File
@@ -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
}
+38 -14
View File
@@ -21,10 +21,11 @@ type DashboardService struct {
repo DashboardRepo repo DashboardRepo
recurrSvc *RecurringService recurrSvc *RecurringService
patrimony PatrimonySource patrimony PatrimonySource
billSvc *CreditBillService
} }
func NewDashboardService(repo DashboardRepo, recurrSvc *RecurringService, patrimony PatrimonySource) *DashboardService { func NewDashboardService(repo DashboardRepo, recurrSvc *RecurringService, patrimony PatrimonySource, billSvc *CreditBillService) *DashboardService {
return &DashboardService{repo: repo, recurrSvc: recurrSvc, patrimony: patrimony} return &DashboardService{repo: repo, recurrSvc: recurrSvc, patrimony: patrimony, billSvc: billSvc}
} }
func (s *DashboardService) Get(ctx context.Context, month string) (*model.DashboardData, error) { 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 return nil, err
} }
pending := 0 pending := 0
for _, s := range statuses { var pendingIncome []model.PendingIncome
if !s.Covered { for _, st := range statuses {
pending++ 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 { if byCategory == nil {
byCategory = []model.CategoryTotal{} byCategory = []model.CategoryTotal{}
@@ -79,15 +96,22 @@ func (s *DashboardService) Get(ctx context.Context, month string) (*model.Dashbo
return nil, err return nil, err
} }
currentBills, _ := s.billSvc.currentBillsForDashboard(ctx)
if currentBills == nil {
currentBills = []model.CreditBill{}
}
return &model.DashboardData{ return &model.DashboardData{
Month: month, Month: month,
TotalIncome: income, TotalIncome: income,
TotalExpenses: expenses, TotalExpenses: expenses,
SavingsPct: savingsPct, SavingsPct: savingsPct,
TotalPatrimony: patrimony, TotalPatrimony: patrimony,
ByCategory: byCategory, ByCategory: byCategory,
MonthlyEvolution: evolution, MonthlyEvolution: evolution,
RecentTransactions: recent, RecentTransactions: recent,
PendingRecurring: pending, PendingRecurring: pending,
PendingIncomeRecurrings: pendingIncome,
CurrentBills: currentBills,
}, nil }, nil
} }
+3 -1
View File
@@ -31,8 +31,10 @@ type mockPatrimony struct{}
func (m *mockPatrimony) TotalPatrimony(_ context.Context) (float64, error) { return 0, nil } func (m *mockPatrimony) TotalPatrimony(_ context.Context) (float64, error) { return 0, nil }
func newDashboardSvc(income, expenses float64) *service.DashboardService { func newDashboardSvc(income, expenses float64) *service.DashboardService {
repo := &mockAccountRepo{}
recurrSvc := service.NewRecurringService(newMockRecurring(nil), &mockTxRepo{}) 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) { func TestDashboard_SavingsPct_40(t *testing.T) {
+70 -13
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"strings" "strings"
"time"
"financeiro-carvalho/internal/model" "financeiro-carvalho/internal/model"
"financeiro-carvalho/internal/repository" "financeiro-carvalho/internal/repository"
@@ -13,6 +14,7 @@ var (
ErrRecurringEmptyName = errors.New("name is required") ErrRecurringEmptyName = errors.New("name is required")
ErrRecurringInvalidAmount = errors.New("expected_amount must be greater than zero") ErrRecurringInvalidAmount = errors.New("expected_amount must be greater than zero")
ErrRecurringInvalidDayOfMonth = errors.New("day_of_month must be between 1 and 31") 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 { type RecurringService struct {
@@ -52,7 +54,7 @@ func (s *RecurringService) Delete(ctx context.Context, id int) error {
return s.repo.Delete(ctx, id) 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) { func (s *RecurringService) MonthlyStatus(ctx context.Context, month string) ([]model.RecurringStatus, error) {
items, err := s.repo.List(ctx) items, err := s.repo.List(ctx)
if err != nil { if err != nil {
@@ -64,27 +66,82 @@ func (s *RecurringService) MonthlyStatus(ctx context.Context, month string) ([]m
if !re.Active { if !re.Active {
continue continue
} }
ignored, reason, err := s.repo.IsIgnored(ctx, re.ID, month)
if err != nil { txType := re.Type
return nil, err if txType == "" {
txType = "expense"
} }
covered := false
if !ignored { if txType == "expense" {
covered, err = s.txRepo.HasMatchingTransaction(ctx, re.CategoryID, month, re.ExpectedAmount) ignored, reason, err := s.repo.IsIgnored(ctx, re.ID, month)
if err != nil { if err != nil {
return nil, err return nil, err
} }
covered := false
if !ignored {
covered, err = s.txRepo.HasMatchingTransaction(ctx, re.CategoryID, month, re.ExpectedAmount, "expense")
if err != nil {
return nil, err
}
}
result = append(result, model.RecurringStatus{
RecurringExpense: re,
Covered: covered || ignored,
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,
})
} }
result = append(result, model.RecurringStatus{
RecurringExpense: re,
Covered: covered || ignored,
Ignored: ignored,
Reason: reason,
})
} }
return result, nil 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 { func (s *RecurringService) Ignore(ctx context.Context, id int, month, reason string) error {
return s.repo.Ignore(ctx, id, month, reason) return s.repo.Ignore(ctx, id, month, reason)
} }
+72 -6
View File
@@ -14,10 +14,11 @@ import (
type mockRecurringRepo struct { type mockRecurringRepo struct {
items []model.RecurringExpense items []model.RecurringExpense
ignores map[string]string // "id:month" → reason ignores map[string]string // "id:month" → reason
lates map[string]bool // "id:month" → true
} }
func newMockRecurring(items []model.RecurringExpense) *mockRecurringRepo { 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) { 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 return nil, repository.ErrNotFound
} }
func (m *mockRecurringRepo) Create(_ context.Context, in model.RecurringInput) (*model.RecurringExpense, error) { 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) m.items = append(m.items, r)
return &r, nil 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) delete(m.ignores, string(rune(id))+":"+month)
return nil 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 ───────────────────────────────────────────────────────────── // ── mock tx repo ─────────────────────────────────────────────────────────────
@@ -78,7 +90,7 @@ func (m *mockTxRepo) Update(_ context.Context, t model.Transaction) (*model.Tran
return &t, nil return &t, nil
} }
func (m *mockTxRepo) Delete(_ context.Context, _ int) error { return 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 return m.matchResult, nil
} }
@@ -87,7 +99,7 @@ func (m *mockTxRepo) HasMatchingTransaction(_ context.Context, _ *int, _ string,
var catID = 1 var catID = 1
func TestMonthlyStatus_Covered(t *testing.T) { 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}) svc := service.NewRecurringService(newMockRecurring([]model.RecurringExpense{re}), &mockTxRepo{matchResult: true})
statuses, err := svc.MonthlyStatus(context.Background(), "2024-03") statuses, err := svc.MonthlyStatus(context.Background(), "2024-03")
@@ -103,7 +115,7 @@ func TestMonthlyStatus_Covered(t *testing.T) {
} }
func TestMonthlyStatus_Uncovered(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}) svc := service.NewRecurringService(newMockRecurring([]model.RecurringExpense{re}), &mockTxRepo{matchResult: false})
statuses, err := svc.MonthlyStatus(context.Background(), "2024-03") 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) { 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 := newMockRecurring([]model.RecurringExpense{re})
_ = repo.Ignore(context.Background(), 3, "2024-03", "viagem") _ = repo.Ignore(context.Background(), 3, "2024-03", "viagem")
svc := service.NewRecurringService(repo, &mockTxRepo{matchResult: false}) 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) { func TestCreateRecurring_Validation(t *testing.T) {
svc := service.NewRecurringService(newMockRecurring(nil), &mockTxRepo{}) svc := service.NewRecurringService(newMockRecurring(nil), &mockTxRepo{})
+1 -1
View File
@@ -49,7 +49,7 @@ const navItems = [
{ to: '/importar', label: 'IMPORT' }, { to: '/importar', label: 'IMPORT' },
{ to: '/contas', label: 'CONTAS' }, { to: '/contas', label: 'CONTAS' },
{ to: '/personagem', label: 'PERS.' }, { to: '/personagem', label: 'PERS.' },
{ to: '/configuracoes', label: 'CFG' }, { to: '/recorrencias', label: 'REC.' },
] ]
</script> </script>
+6 -2
View File
@@ -41,10 +41,14 @@ const router = createRouter({
name: 'character', name: 'character',
component: () => import('../views/CharacterView.vue'), component: () => import('../views/CharacterView.vue'),
}, },
{
path: '/recorrencias',
name: 'recurring',
component: () => import('../views/RecurringView.vue'),
},
{ {
path: '/configuracoes', path: '/configuracoes',
name: 'settings', redirect: '/recorrencias',
component: () => import('../views/SettingsView.vue'),
}, },
], ],
}) })
+27 -1
View File
@@ -2,12 +2,30 @@ import { defineStore } from 'pinia'
import { ref } from 'vue' import { ref } from 'vue'
import { api } from '@/services/api' 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 { export interface Account {
id: number id: number
name: string name: string
type: 'checking' | 'savings' | 'investment' | 'credit' type: 'checking' | 'savings' | 'investment' | 'credit'
initial_balance: number initial_balance: number
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 created_at: string
updated_at: string updated_at: string
} }
@@ -16,6 +34,9 @@ export interface AccountInput {
name: string name: string
type: string type: string
initial_balance: number initial_balance: number
yield_type: string
closing_day?: number | null
due_day?: number | null
} }
export const useAccountsStore = defineStore('accounts', () => { export const useAccountsStore = defineStore('accounts', () => {
@@ -53,5 +74,10 @@ export const useAccountsStore = defineStore('accounts', () => {
accounts.value = accounts.value.filter((a) => a.id !== id) 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 }
}) })
+11
View File
@@ -1,6 +1,7 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref } from 'vue' import { ref } from 'vue'
import { api } from '@/services/api' import { api } from '@/services/api'
import type { CreditBill } from './accounts'
export interface CategoryTotal { export interface CategoryTotal {
category_id: number | null category_id: number | null
@@ -25,6 +26,14 @@ export interface RecentTransaction {
type: 'income' | 'expense' type: 'income' | 'expense'
} }
export interface PendingIncome {
id: number
name: string
expected_amount: number
day_of_month: number
late: boolean
}
export interface DashboardData { export interface DashboardData {
month: string month: string
total_income: number total_income: number
@@ -35,6 +44,8 @@ export interface DashboardData {
monthly_evolution: MonthEvolution[] monthly_evolution: MonthEvolution[]
recent_transactions: RecentTransaction[] recent_transactions: RecentTransaction[]
pending_recurring: number pending_recurring: number
pending_income_recurrings: PendingIncome[]
current_bills: CreditBill[]
} }
export const useDashboardStore = defineStore('dashboard', () => { export const useDashboardStore = defineStore('dashboard', () => {
+28 -2
View File
@@ -1,7 +1,6 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref } from 'vue' import { ref } from 'vue'
import { api } from '@/services/api' import { api } from '@/services/api'
import type { Category } from './categories'
export interface RecurringExpense { export interface RecurringExpense {
id: number id: number
@@ -9,6 +8,7 @@ export interface RecurringExpense {
expected_amount: number expected_amount: number
day_of_month: number day_of_month: number
category_id: number | null category_id: number | null
type: 'income' | 'expense'
active: boolean active: boolean
created_at: string created_at: string
updated_at: string updated_at: string
@@ -17,6 +17,7 @@ export interface RecurringExpense {
export interface RecurringStatus extends RecurringExpense { export interface RecurringStatus extends RecurringExpense {
covered: boolean covered: boolean
ignored: boolean ignored: boolean
late: boolean
reason?: string reason?: string
} }
@@ -74,8 +75,33 @@ export const useRecurringStore = defineStore('recurring', () => {
await fetchMonthlyStatus(month) 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) => const pendingCount = (month: string) =>
monthlyStatus.value.filter((s) => !s.covered).length 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,
}
}) })
+79 -5
View File
@@ -13,7 +13,7 @@ const typeLabels: Record<string, string> = {
credit: 'Cartão de Crédito', 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 form = ref(blank())
const editId = ref<number | null>(null) const editId = ref<number | null>(null)
const initialRaw = ref('') const initialRaw = ref('')
@@ -27,11 +27,21 @@ function startEdit(id: number) {
const a = store.accounts.find((x) => x.id === id) const a = store.accounts.find((x) => x.id === id)
if (!a) return if (!a) return
editId.value = id 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 }) initialRaw.value = a.initial_balance.toLocaleString('pt-BR', { minimumFractionDigits: 2 })
formError.value = null formError.value = null
} }
async function payBill(billId: number) {
if (!confirm('Marcar fatura como PAGA?')) return
await store.payBill(billId)
}
function cancelEdit() { function cancelEdit() {
editId.value = null editId.value = null
form.value = blank() form.value = blank()
@@ -85,6 +95,21 @@ function totalBalance() {
<option value="investment">Investimento</option> <option value="investment">Investimento</option>
<option value="credit">Cartão de Crédito</option> <option value="credit">Cartão de Crédito</option>
</select> </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 <input
v-model="initialRaw" v-model="initialRaw"
placeholder="Saldo inicial (ex: 1.500,00)" placeholder="Saldo inicial (ex: 1.500,00)"
@@ -120,7 +145,11 @@ function totalBalance() {
:class="{ 'fc-acc-item--editing': editId === a.id }" :class="{ 'fc-acc-item--editing': editId === a.id }"
> >
<div class="fc-acc-item__info"> <div class="fc-acc-item__info">
<span class="fc-body fc-acc-item__name">{{ a.name }}</span> <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"> <span class="fc-mono fc-acc-item__meta">
{{ typeLabels[a.type] }} · inicial {{ fmt(a.initial_balance) }} {{ typeLabels[a.type] }} · inicial {{ fmt(a.initial_balance) }}
</span> </span>
@@ -138,6 +167,32 @@ function totalBalance() {
<li v-if="store.accounts.length === 0" class="fc-acc-empty fc-mono"> nenhuma conta cadastrada </li> <li v-if="store.accounts.length === 0" class="fc-acc-empty fc-mono"> nenhuma conta cadastrada </li>
</ul> </ul>
</NeonPanel> </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> </div>
</template> </template>
@@ -165,8 +220,9 @@ function totalBalance() {
} }
.fc-acc-form__name { flex: 1; min-width: 160px; } .fc-acc-form__name { flex: 1; min-width: 160px; }
.fc-acc-form__type { width: 160px; } .fc-acc-form__type { width: 150px; }
.fc-acc-form__balance { width: 160px; } .fc-acc-form__yield { width: 160px; }
.fc-acc-form__balance { width: 150px; }
.fc-acc-form__error { .fc-acc-form__error {
color: var(--fc-red); color: var(--fc-red);
@@ -237,9 +293,27 @@ function totalBalance() {
gap: 4px; 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__name { font-size: 14px; font-weight: 500; }
.fc-acc-item__meta { font-size: 11px; color: var(--fc-text-dim); } .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 { .fc-acc-item__right {
display: flex; display: flex;
align-items: center; align-items: center;
+102 -1
View File
@@ -2,6 +2,8 @@
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useDashboardStore } from '@/stores/dashboard' import { useDashboardStore } from '@/stores/dashboard'
import { useGameStore } from '@/stores/game' import { useGameStore } from '@/stores/game'
import { useRecurringStore } from '@/stores/recurring'
import { useAccountsStore } from '@/stores/accounts'
import NeonPanel from '@/components/NeonPanel.vue' import NeonPanel from '@/components/NeonPanel.vue'
import XPBar from '@/components/XPBar.vue' import XPBar from '@/components/XPBar.vue'
import CharacterSprite from '@/components/CharacterSprite.vue' import CharacterSprite from '@/components/CharacterSprite.vue'
@@ -9,6 +11,8 @@ import MonthSwitcher from '@/components/MonthSwitcher.vue'
const dash = useDashboardStore() const dash = useDashboardStore()
const game = useGameStore() const game = useGameStore()
const recurring = useRecurringStore()
const accountsStore = useAccountsStore()
const currentMonth = ref(new Date().toISOString().slice(0, 7)) const currentMonth = ref(new Date().toISOString().slice(0, 7))
onMounted(() => dash.fetch(currentMonth.value)) onMounted(() => dash.fetch(currentMonth.value))
@@ -51,6 +55,29 @@ const xpPct = computed(() => {
}) })
const savingsPct = computed(() => dash.data?.savings_pct ?? 0) const savingsPct = computed(() => dash.data?.savings_pct ?? 0)
const savingsOk = computed(() => savingsPct.value >= 40) 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> </script>
<template> <template>
@@ -64,7 +91,7 @@ const savingsOk = computed(() => savingsPct.value >= 40)
<div v-if="dash.loading" class="dash-loading fc-pixel">CARREGANDO...</div> <div v-if="dash.loading" class="dash-loading fc-pixel">CARREGANDO...</div>
<template v-else-if="dash.data"> <template v-else-if="dash.data">
<!-- Alert banner --> <!-- Alert banner despesas recorrentes ausentes -->
<div v-if="dash.data.pending_recurring > 0" class="dash-alert"> <div v-if="dash.data.pending_recurring > 0" class="dash-alert">
<span class="fc-blink" style="color:var(--fc-red)"></span> <span class="fc-blink" style="color:var(--fc-red)"></span>
<span class="fc-pixel" style="font-size:8px"> <span class="fc-pixel" style="font-size:8px">
@@ -72,6 +99,37 @@ const savingsOk = computed(() => savingsPct.value >= 40)
</span> </span>
</div> </div>
<!-- Income confirmation widget (day 15 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"> <div class="dash-grid">
<!-- Left column --> <!-- Left column -->
<div class="dash-col-main"> <div class="dash-col-main">
@@ -200,6 +258,21 @@ const savingsOk = computed(() => savingsPct.value >= 40)
</div> </div>
</div> </div>
</NeonPanel> </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>
</div> </div>
</template> </template>
@@ -289,6 +362,34 @@ const savingsOk = computed(() => savingsPct.value >= 40)
.empty { font-size: 8px; color: var(--fc-text-dim); padding: 8px 0; } .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) { @media (max-width: 767px) {
.dash-page { padding: 14px 12px 80px; } .dash-page { padding: 14px 12px 80px; }
.tx-row { grid-template-columns: 70px 1fr 80px; } .tx-row { grid-template-columns: 70px 1fr 80px; }
+293
View File
@@ -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>