diff --git a/apps/api/internal/migration/migration.go b/apps/api/internal/migration/migration.go index d9f5eed..3f22e6d 100644 --- a/apps/api/internal/migration/migration.go +++ b/apps/api/internal/migration/migration.go @@ -41,6 +41,9 @@ var m010 string //go:embed sql/011_credit_bills.sql var m011 string +//go:embed sql/012_category_is_tax.sql +var m012 string + func Run(ctx context.Context, pool *pgxpool.Pool) error { // Bootstrap: ensure schema_migrations table exists before checking versions. if _, err := pool.Exec(ctx, ` @@ -52,7 +55,7 @@ func Run(ctx context.Context, pool *pgxpool.Pool) error { return fmt.Errorf("bootstrap schema_migrations: %w", err) } - migrations := []string{m001, m002, m003, m004, m005, m006, m007, m008, m009, m010, m011} + migrations := []string{m001, m002, m003, m004, m005, m006, m007, m008, m009, m010, m011, m012} for i, sql := range migrations { version := i + 1 var applied bool diff --git a/apps/api/internal/migration/sql/012_category_is_tax.sql b/apps/api/internal/migration/sql/012_category_is_tax.sql new file mode 100644 index 0000000..3a947cf --- /dev/null +++ b/apps/api/internal/migration/sql/012_category_is_tax.sql @@ -0,0 +1 @@ +ALTER TABLE categories ADD COLUMN IF NOT EXISTS is_tax BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/apps/api/internal/model/category.go b/apps/api/internal/model/category.go index 18f3162..d16309f 100644 --- a/apps/api/internal/model/category.go +++ b/apps/api/internal/model/category.go @@ -7,6 +7,7 @@ type Category struct { Name string `json:"name"` Color string `json:"color"` IsDefault bool `json:"is_default"` + IsTax bool `json:"is_tax"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } @@ -14,4 +15,5 @@ type Category struct { type CategoryInput struct { Name string `json:"name"` Color string `json:"color"` + IsTax bool `json:"is_tax"` } diff --git a/apps/api/internal/repository/category.go b/apps/api/internal/repository/category.go index 8e2d7d8..4c2b49a 100644 --- a/apps/api/internal/repository/category.go +++ b/apps/api/internal/repository/category.go @@ -15,8 +15,8 @@ var ErrNotFound = errors.New("not found") type CategoryRepository interface { List(ctx context.Context) ([]model.Category, error) GetByID(ctx context.Context, id int) (*model.Category, error) - Create(ctx context.Context, name, color string) (*model.Category, error) - Update(ctx context.Context, id int, name, color string) (*model.Category, error) + Create(ctx context.Context, name, color string, isTax bool) (*model.Category, error) + Update(ctx context.Context, id int, name, color string, isTax bool) (*model.Category, error) Delete(ctx context.Context, id int) error HasTransactions(ctx context.Context, id int) (bool, error) } @@ -29,7 +29,7 @@ func NewCategoryRepository(db *pgxpool.Pool) CategoryRepository { func (r *categoryRepo) List(ctx context.Context) ([]model.Category, error) { rows, err := r.db.Query(ctx, ` - SELECT id, name, color, is_default, created_at, updated_at + SELECT id, name, color, is_default, is_tax, created_at, updated_at FROM categories ORDER BY is_default DESC, name ASC`) if err != nil { @@ -40,7 +40,7 @@ func (r *categoryRepo) List(ctx context.Context) ([]model.Category, error) { var out []model.Category for rows.Next() { var c model.Category - if err := rows.Scan(&c.ID, &c.Name, &c.Color, &c.IsDefault, &c.CreatedAt, &c.UpdatedAt); err != nil { + if err := rows.Scan(&c.ID, &c.Name, &c.Color, &c.IsDefault, &c.IsTax, &c.CreatedAt, &c.UpdatedAt); err != nil { return nil, err } out = append(out, c) @@ -51,35 +51,35 @@ func (r *categoryRepo) List(ctx context.Context) ([]model.Category, error) { func (r *categoryRepo) GetByID(ctx context.Context, id int) (*model.Category, error) { var c model.Category err := r.db.QueryRow(ctx, ` - SELECT id, name, color, is_default, created_at, updated_at + SELECT id, name, color, is_default, is_tax, created_at, updated_at FROM categories WHERE id = $1`, id). - Scan(&c.ID, &c.Name, &c.Color, &c.IsDefault, &c.CreatedAt, &c.UpdatedAt) + Scan(&c.ID, &c.Name, &c.Color, &c.IsDefault, &c.IsTax, &c.CreatedAt, &c.UpdatedAt) if errors.Is(err, pgx.ErrNoRows) { return nil, ErrNotFound } return &c, err } -func (r *categoryRepo) Create(ctx context.Context, name, color string) (*model.Category, error) { +func (r *categoryRepo) Create(ctx context.Context, name, color string, isTax bool) (*model.Category, error) { var c model.Category err := r.db.QueryRow(ctx, ` - INSERT INTO categories (name, color) - VALUES ($1, $2) - RETURNING id, name, color, is_default, created_at, updated_at`, - name, color). - Scan(&c.ID, &c.Name, &c.Color, &c.IsDefault, &c.CreatedAt, &c.UpdatedAt) + INSERT INTO categories (name, color, is_tax) + VALUES ($1, $2, $3) + RETURNING id, name, color, is_default, is_tax, created_at, updated_at`, + name, color, isTax). + Scan(&c.ID, &c.Name, &c.Color, &c.IsDefault, &c.IsTax, &c.CreatedAt, &c.UpdatedAt) return &c, err } -func (r *categoryRepo) Update(ctx context.Context, id int, name, color string) (*model.Category, error) { +func (r *categoryRepo) Update(ctx context.Context, id int, name, color string, isTax bool) (*model.Category, error) { var c model.Category err := r.db.QueryRow(ctx, ` UPDATE categories - SET name = $1, color = $2, updated_at = NOW() - WHERE id = $3 - RETURNING id, name, color, is_default, created_at, updated_at`, - name, color, id). - Scan(&c.ID, &c.Name, &c.Color, &c.IsDefault, &c.CreatedAt, &c.UpdatedAt) + SET name = $1, color = $2, is_tax = $3, updated_at = NOW() + WHERE id = $4 + RETURNING id, name, color, is_default, is_tax, created_at, updated_at`, + name, color, isTax, id). + Scan(&c.ID, &c.Name, &c.Color, &c.IsDefault, &c.IsTax, &c.CreatedAt, &c.UpdatedAt) if errors.Is(err, pgx.ErrNoRows) { return nil, ErrNotFound } diff --git a/apps/api/internal/repository/dashboard.go b/apps/api/internal/repository/dashboard.go index 3d1abed..9a26be0 100644 --- a/apps/api/internal/repository/dashboard.go +++ b/apps/api/internal/repository/dashboard.go @@ -90,6 +90,19 @@ func (r *DashboardRepository) MonthlyEvolution(ctx context.Context, month string return out, rows.Err() } +func (r *DashboardRepository) TotalTaxes(ctx context.Context, month string) (float64, error) { + var total float64 + err := r.pool.QueryRow(ctx, ` + SELECT COALESCE(SUM(t.amount), 0) + FROM transactions t + JOIN categories c ON c.id = t.category_id + WHERE to_char(t.date, 'YYYY-MM') = $1 + AND t.type = 'expense' + AND c.is_tax = TRUE + `, month).Scan(&total) + return total, err +} + func (r *DashboardRepository) RecentTransactions(ctx context.Context, month string) ([]model.RecentTransaction, error) { rows, err := r.pool.Query(ctx, ` SELECT diff --git a/apps/api/internal/service/category.go b/apps/api/internal/service/category.go index f46224b..49cd817 100644 --- a/apps/api/internal/service/category.go +++ b/apps/api/internal/service/category.go @@ -32,14 +32,14 @@ func (s *CategoryService) Create(ctx context.Context, in model.CategoryInput) (* if err := validateInput(in); err != nil { return nil, err } - return s.repo.Create(ctx, strings.TrimSpace(in.Name), in.Color) + return s.repo.Create(ctx, strings.TrimSpace(in.Name), in.Color, in.IsTax) } func (s *CategoryService) Update(ctx context.Context, id int, in model.CategoryInput) (*model.Category, error) { if err := validateInput(in); err != nil { return nil, err } - return s.repo.Update(ctx, id, strings.TrimSpace(in.Name), in.Color) + return s.repo.Update(ctx, id, strings.TrimSpace(in.Name), in.Color, in.IsTax) } func (s *CategoryService) Delete(ctx context.Context, id int) error { diff --git a/apps/api/internal/service/dashboard.go b/apps/api/internal/service/dashboard.go index 10520fb..b0800ad 100644 --- a/apps/api/internal/service/dashboard.go +++ b/apps/api/internal/service/dashboard.go @@ -8,6 +8,7 @@ import ( type DashboardRepo interface { MonthlySummary(ctx context.Context, month string) (income, expenses float64, err error) + TotalTaxes(ctx context.Context, month string) (float64, error) ByCategory(ctx context.Context, month string) ([]model.CategoryTotal, error) MonthlyEvolution(ctx context.Context, month string) ([]model.MonthEvolution, error) RecentTransactions(ctx context.Context, month string) ([]model.RecentTransaction, error) @@ -34,6 +35,11 @@ func (s *DashboardService) Get(ctx context.Context, month string) (*model.Dashbo return nil, err } + taxes, err := s.repo.TotalTaxes(ctx, month) + if err != nil { + return nil, err + } + byCategory, err := s.repo.ByCategory(ctx, month) if err != nil { return nil, err @@ -49,9 +55,11 @@ func (s *DashboardService) Get(ctx context.Context, month string) (*model.Dashbo return nil, err } + // savings_pct uses net income (gross - taxes) as denominator (Option B) var savingsPct float64 - if income > 0 { - savingsPct = (income - expenses) / income * 100 + netIncome := income - taxes + if netIncome > 0 { + savingsPct = (income - expenses) / netIncome * 100 } statuses, err := s.recurrSvc.MonthlyStatus(ctx, month) diff --git a/apps/api/internal/service/dashboard_test.go b/apps/api/internal/service/dashboard_test.go index 250566f..5689934 100644 --- a/apps/api/internal/service/dashboard_test.go +++ b/apps/api/internal/service/dashboard_test.go @@ -16,6 +16,9 @@ type mockDashboardRepo struct { func (m *mockDashboardRepo) MonthlySummary(_ context.Context, _ string) (float64, float64, error) { return m.income, m.expenses, nil } +func (m *mockDashboardRepo) TotalTaxes(_ context.Context, _ string) (float64, error) { + return 0, nil +} func (m *mockDashboardRepo) ByCategory(_ context.Context, _ string) ([]model.CategoryTotal, error) { return nil, nil } diff --git a/apps/web/src/stores/categories.ts b/apps/web/src/stores/categories.ts index b3ce1c4..e19795f 100644 --- a/apps/web/src/stores/categories.ts +++ b/apps/web/src/stores/categories.ts @@ -7,6 +7,7 @@ export interface Category { name: string color: string is_default: boolean + is_tax: boolean created_at: string updated_at: string } @@ -28,14 +29,14 @@ export const useCategoriesStore = defineStore('categories', () => { } } - async function create(name: string, color: string) { - const cat = await api.post('/categories', { name, color }) + async function create(name: string, color: string, isTax = false) { + const cat = await api.post('/categories', { name, color, is_tax: isTax }) categories.value.push(cat) return cat } - async function update(id: number, name: string, color: string) { - const cat = await api.put(`/categories/${id}`, { name, color }) + async function update(id: number, name: string, color: string, isTax = false) { + const cat = await api.put(`/categories/${id}`, { name, color, is_tax: isTax }) const idx = categories.value.findIndex((c) => c.id === id) if (idx !== -1) categories.value[idx] = cat return cat diff --git a/apps/web/src/views/CategoriesView.vue b/apps/web/src/views/CategoriesView.vue index 372a32e..0429715 100644 --- a/apps/web/src/views/CategoriesView.vue +++ b/apps/web/src/views/CategoriesView.vue @@ -6,19 +6,19 @@ import NeonPanel from '@/components/NeonPanel.vue' const store = useCategoriesStore() onMounted(() => store.fetchAll()) -const form = ref({ name: '', color: '#a855f7' }) +const form = ref({ name: '', color: '#a855f7', is_tax: false }) const editId = ref(null) const formError = ref(null) -function startEdit(id: number, name: string, color: string) { +function startEdit(id: number, name: string, color: string, isTax: boolean) { editId.value = id - form.value = { name, color } + form.value = { name, color, is_tax: isTax } formError.value = null } function cancelEdit() { editId.value = null - form.value = { name: '', color: '#a855f7' } + form.value = { name: '', color: '#a855f7', is_tax: false } formError.value = null } @@ -26,11 +26,11 @@ async function submit() { formError.value = null try { if (editId.value !== null) { - await store.update(editId.value, form.value.name, form.value.color) + await store.update(editId.value, form.value.name, form.value.color, form.value.is_tax) cancelEdit() } else { - await store.create(form.value.name, form.value.color) - form.value = { name: '', color: '#a855f7' } + await store.create(form.value.name, form.value.color, form.value.is_tax) + form.value = { name: '', color: '#a855f7', is_tax: false } } } catch (e: any) { formError.value = e.message @@ -61,6 +61,10 @@ async function remove(id: number, name: string) { {{ form.color }} +

{{ formError }}

@@ -84,8 +88,9 @@ async function remove(id: number, name: string) { {{ cat.name }} PADRÃO + IMPOSTO
- +