feat: recurring expenses and reports
This commit is contained in:
parent
cda4f61939
commit
9e6ff67a87
23 changed files with 2943 additions and 56 deletions
77
internal/repository/budget.go
Normal file
77
internal/repository/budget.go
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
package repository
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"git.juancwu.dev/juancwu/budgit/internal/model"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrBudgetNotFound = errors.New("budget not found")
|
||||
)
|
||||
|
||||
type BudgetRepository interface {
|
||||
Create(budget *model.Budget) error
|
||||
GetByID(id string) (*model.Budget, error)
|
||||
GetBySpaceID(spaceID string) ([]*model.Budget, error)
|
||||
GetSpentForBudget(spaceID, tagID string, periodStart, periodEnd time.Time) (int, error)
|
||||
Update(budget *model.Budget) error
|
||||
Delete(id string) error
|
||||
}
|
||||
|
||||
type budgetRepository struct {
|
||||
db *sqlx.DB
|
||||
}
|
||||
|
||||
func NewBudgetRepository(db *sqlx.DB) BudgetRepository {
|
||||
return &budgetRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *budgetRepository) Create(budget *model.Budget) error {
|
||||
query := `INSERT INTO budgets (id, space_id, tag_id, amount_cents, period, start_date, end_date, is_active, created_by, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11);`
|
||||
_, err := r.db.Exec(query, budget.ID, budget.SpaceID, budget.TagID, budget.AmountCents, budget.Period, budget.StartDate, budget.EndDate, budget.IsActive, budget.CreatedBy, budget.CreatedAt, budget.UpdatedAt)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *budgetRepository) GetByID(id string) (*model.Budget, error) {
|
||||
budget := &model.Budget{}
|
||||
err := r.db.Get(budget, `SELECT * FROM budgets WHERE id = $1;`, id)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, ErrBudgetNotFound
|
||||
}
|
||||
return budget, err
|
||||
}
|
||||
|
||||
func (r *budgetRepository) GetBySpaceID(spaceID string) ([]*model.Budget, error) {
|
||||
var budgets []*model.Budget
|
||||
err := r.db.Select(&budgets, `SELECT * FROM budgets WHERE space_id = $1 ORDER BY created_at DESC;`, spaceID)
|
||||
return budgets, err
|
||||
}
|
||||
|
||||
func (r *budgetRepository) GetSpentForBudget(spaceID, tagID string, periodStart, periodEnd time.Time) (int, error) {
|
||||
var spent int
|
||||
query := `
|
||||
SELECT COALESCE(SUM(e.amount_cents), 0)
|
||||
FROM expenses e
|
||||
JOIN expense_tags et ON e.id = et.expense_id
|
||||
WHERE e.space_id = $1 AND et.tag_id = $2 AND e.type = 'expense'
|
||||
AND e.date >= $3 AND e.date <= $4;
|
||||
`
|
||||
err := r.db.Get(&spent, query, spaceID, tagID, periodStart, periodEnd)
|
||||
return spent, err
|
||||
}
|
||||
|
||||
func (r *budgetRepository) Update(budget *model.Budget) error {
|
||||
query := `UPDATE budgets SET tag_id = $1, amount_cents = $2, period = $3, start_date = $4, end_date = $5, is_active = $6, updated_at = $7 WHERE id = $8;`
|
||||
_, err := r.db.Exec(query, budget.TagID, budget.AmountCents, budget.Period, budget.StartDate, budget.EndDate, budget.IsActive, budget.UpdatedAt, budget.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *budgetRepository) Delete(id string) error {
|
||||
_, err := r.db.Exec(`DELETE FROM budgets WHERE id = $1;`, id)
|
||||
return err
|
||||
}
|
||||
|
|
@ -24,6 +24,11 @@ type ExpenseRepository interface {
|
|||
GetPaymentMethodsByExpenseIDs(expenseIDs []string) (map[string]*model.PaymentMethod, error)
|
||||
Update(expense *model.Expense, tagIDs []string) error
|
||||
Delete(id string) error
|
||||
// Report queries
|
||||
GetDailySpending(spaceID string, from, to time.Time) ([]*model.DailySpending, error)
|
||||
GetMonthlySpending(spaceID string, from, to time.Time) ([]*model.MonthlySpending, error)
|
||||
GetTopExpenses(spaceID string, from, to time.Time, limit int) ([]*model.Expense, error)
|
||||
GetIncomeVsExpenseSummary(spaceID string, from, to time.Time) (int, int, error)
|
||||
}
|
||||
|
||||
type expenseRepository struct {
|
||||
|
|
@ -42,9 +47,9 @@ func (r *expenseRepository) Create(expense *model.Expense, tagIDs []string, item
|
|||
defer tx.Rollback()
|
||||
|
||||
// Insert Expense
|
||||
queryExpense := `INSERT INTO expenses (id, space_id, created_by, description, amount_cents, type, date, payment_method_id, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10);`
|
||||
_, err = tx.Exec(queryExpense, expense.ID, expense.SpaceID, expense.CreatedBy, expense.Description, expense.AmountCents, expense.Type, expense.Date, expense.PaymentMethodID, expense.CreatedAt, expense.UpdatedAt)
|
||||
queryExpense := `INSERT INTO expenses (id, space_id, created_by, description, amount_cents, type, date, payment_method_id, recurring_expense_id, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11);`
|
||||
_, err = tx.Exec(queryExpense, expense.ID, expense.SpaceID, expense.CreatedBy, expense.Description, expense.AmountCents, expense.Type, expense.Date, expense.PaymentMethodID, expense.RecurringExpenseID, expense.CreatedAt, expense.UpdatedAt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -252,3 +257,69 @@ func (r *expenseRepository) Delete(id string) error {
|
|||
_, err := r.db.Exec(`DELETE FROM expenses WHERE id = $1;`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *expenseRepository) GetDailySpending(spaceID string, from, to time.Time) ([]*model.DailySpending, error) {
|
||||
var results []*model.DailySpending
|
||||
query := `
|
||||
SELECT date, SUM(amount_cents) as total_cents
|
||||
FROM expenses
|
||||
WHERE space_id = $1 AND type = 'expense' AND date >= $2 AND date <= $3
|
||||
GROUP BY date
|
||||
ORDER BY date ASC;
|
||||
`
|
||||
err := r.db.Select(&results, query, spaceID, from, to)
|
||||
return results, err
|
||||
}
|
||||
|
||||
func (r *expenseRepository) GetMonthlySpending(spaceID string, from, to time.Time) ([]*model.MonthlySpending, error) {
|
||||
var results []*model.MonthlySpending
|
||||
query := `
|
||||
SELECT TO_CHAR(date, 'YYYY-MM') as month, SUM(amount_cents) as total_cents
|
||||
FROM expenses
|
||||
WHERE space_id = $1 AND type = 'expense' AND date >= $2 AND date <= $3
|
||||
GROUP BY TO_CHAR(date, 'YYYY-MM')
|
||||
ORDER BY month ASC;
|
||||
`
|
||||
err := r.db.Select(&results, query, spaceID, from, to)
|
||||
return results, err
|
||||
}
|
||||
|
||||
func (r *expenseRepository) GetTopExpenses(spaceID string, from, to time.Time, limit int) ([]*model.Expense, error) {
|
||||
var results []*model.Expense
|
||||
query := `
|
||||
SELECT * FROM expenses
|
||||
WHERE space_id = $1 AND type = 'expense' AND date >= $2 AND date <= $3
|
||||
ORDER BY amount_cents DESC
|
||||
LIMIT $4;
|
||||
`
|
||||
err := r.db.Select(&results, query, spaceID, from, to, limit)
|
||||
return results, err
|
||||
}
|
||||
|
||||
func (r *expenseRepository) GetIncomeVsExpenseSummary(spaceID string, from, to time.Time) (int, int, error) {
|
||||
type summary struct {
|
||||
Type string `db:"type"`
|
||||
Total int `db:"total"`
|
||||
}
|
||||
var results []summary
|
||||
query := `
|
||||
SELECT type, COALESCE(SUM(amount_cents), 0) as total
|
||||
FROM expenses
|
||||
WHERE space_id = $1 AND date >= $2 AND date <= $3
|
||||
GROUP BY type;
|
||||
`
|
||||
err := r.db.Select(&results, query, spaceID, from, to)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
|
||||
var income, expenses int
|
||||
for _, r := range results {
|
||||
if r.Type == "topup" {
|
||||
income = r.Total
|
||||
} else if r.Type == "expense" {
|
||||
expenses = r.Total
|
||||
}
|
||||
}
|
||||
return income, expenses, nil
|
||||
}
|
||||
|
|
|
|||
228
internal/repository/recurring_expense.go
Normal file
228
internal/repository/recurring_expense.go
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
package repository
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"git.juancwu.dev/juancwu/budgit/internal/model"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrRecurringExpenseNotFound = errors.New("recurring expense not found")
|
||||
)
|
||||
|
||||
type RecurringExpenseRepository interface {
|
||||
Create(re *model.RecurringExpense, tagIDs []string) error
|
||||
GetByID(id string) (*model.RecurringExpense, error)
|
||||
GetBySpaceID(spaceID string) ([]*model.RecurringExpense, error)
|
||||
GetTagsByRecurringExpenseIDs(ids []string) (map[string][]*model.Tag, error)
|
||||
GetPaymentMethodsByRecurringExpenseIDs(ids []string) (map[string]*model.PaymentMethod, error)
|
||||
Update(re *model.RecurringExpense, tagIDs []string) error
|
||||
Delete(id string) error
|
||||
SetActive(id string, active bool) error
|
||||
GetDueRecurrences(now time.Time) ([]*model.RecurringExpense, error)
|
||||
GetDueRecurrencesForSpace(spaceID string, now time.Time) ([]*model.RecurringExpense, error)
|
||||
UpdateNextOccurrence(id string, next time.Time) error
|
||||
Deactivate(id string) error
|
||||
}
|
||||
|
||||
type recurringExpenseRepository struct {
|
||||
db *sqlx.DB
|
||||
}
|
||||
|
||||
func NewRecurringExpenseRepository(db *sqlx.DB) RecurringExpenseRepository {
|
||||
return &recurringExpenseRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *recurringExpenseRepository) Create(re *model.RecurringExpense, tagIDs []string) error {
|
||||
tx, err := r.db.Beginx()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
query := `INSERT INTO recurring_expenses (id, space_id, created_by, description, amount_cents, type, payment_method_id, frequency, start_date, end_date, next_occurrence, is_active, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14);`
|
||||
_, err = tx.Exec(query, re.ID, re.SpaceID, re.CreatedBy, re.Description, re.AmountCents, re.Type, re.PaymentMethodID, re.Frequency, re.StartDate, re.EndDate, re.NextOccurrence, re.IsActive, re.CreatedAt, re.UpdatedAt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(tagIDs) > 0 {
|
||||
tagQuery := `INSERT INTO recurring_expense_tags (recurring_expense_id, tag_id) VALUES ($1, $2);`
|
||||
for _, tagID := range tagIDs {
|
||||
if _, err := tx.Exec(tagQuery, re.ID, tagID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (r *recurringExpenseRepository) GetByID(id string) (*model.RecurringExpense, error) {
|
||||
re := &model.RecurringExpense{}
|
||||
query := `SELECT * FROM recurring_expenses WHERE id = $1;`
|
||||
err := r.db.Get(re, query, id)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, ErrRecurringExpenseNotFound
|
||||
}
|
||||
return re, err
|
||||
}
|
||||
|
||||
func (r *recurringExpenseRepository) GetBySpaceID(spaceID string) ([]*model.RecurringExpense, error) {
|
||||
var results []*model.RecurringExpense
|
||||
query := `SELECT * FROM recurring_expenses WHERE space_id = $1 ORDER BY is_active DESC, next_occurrence ASC;`
|
||||
err := r.db.Select(&results, query, spaceID)
|
||||
return results, err
|
||||
}
|
||||
|
||||
func (r *recurringExpenseRepository) GetTagsByRecurringExpenseIDs(ids []string) (map[string][]*model.Tag, error) {
|
||||
if len(ids) == 0 {
|
||||
return make(map[string][]*model.Tag), nil
|
||||
}
|
||||
|
||||
type row struct {
|
||||
RecurringExpenseID string `db:"recurring_expense_id"`
|
||||
ID string `db:"id"`
|
||||
SpaceID string `db:"space_id"`
|
||||
Name string `db:"name"`
|
||||
Color *string `db:"color"`
|
||||
}
|
||||
|
||||
query, args, err := sqlx.In(`
|
||||
SELECT ret.recurring_expense_id, t.id, t.space_id, t.name, t.color
|
||||
FROM recurring_expense_tags ret
|
||||
JOIN tags t ON ret.tag_id = t.id
|
||||
WHERE ret.recurring_expense_id IN (?)
|
||||
ORDER BY t.name;
|
||||
`, ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
query = r.db.Rebind(query)
|
||||
|
||||
var rows []row
|
||||
if err := r.db.Select(&rows, query, args...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make(map[string][]*model.Tag)
|
||||
for _, rw := range rows {
|
||||
result[rw.RecurringExpenseID] = append(result[rw.RecurringExpenseID], &model.Tag{
|
||||
ID: rw.ID,
|
||||
SpaceID: rw.SpaceID,
|
||||
Name: rw.Name,
|
||||
Color: rw.Color,
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *recurringExpenseRepository) GetPaymentMethodsByRecurringExpenseIDs(ids []string) (map[string]*model.PaymentMethod, error) {
|
||||
if len(ids) == 0 {
|
||||
return make(map[string]*model.PaymentMethod), nil
|
||||
}
|
||||
|
||||
type row struct {
|
||||
RecurringExpenseID string `db:"recurring_expense_id"`
|
||||
ID string `db:"id"`
|
||||
SpaceID string `db:"space_id"`
|
||||
Name string `db:"name"`
|
||||
Type model.PaymentMethodType `db:"type"`
|
||||
LastFour *string `db:"last_four"`
|
||||
}
|
||||
|
||||
query, args, err := sqlx.In(`
|
||||
SELECT re.id AS recurring_expense_id, pm.id, pm.space_id, pm.name, pm.type, pm.last_four
|
||||
FROM recurring_expenses re
|
||||
JOIN payment_methods pm ON re.payment_method_id = pm.id
|
||||
WHERE re.id IN (?) AND re.payment_method_id IS NOT NULL;
|
||||
`, ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
query = r.db.Rebind(query)
|
||||
|
||||
var rows []row
|
||||
if err := r.db.Select(&rows, query, args...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make(map[string]*model.PaymentMethod)
|
||||
for _, rw := range rows {
|
||||
result[rw.RecurringExpenseID] = &model.PaymentMethod{
|
||||
ID: rw.ID,
|
||||
SpaceID: rw.SpaceID,
|
||||
Name: rw.Name,
|
||||
Type: rw.Type,
|
||||
LastFour: rw.LastFour,
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *recurringExpenseRepository) Update(re *model.RecurringExpense, tagIDs []string) error {
|
||||
tx, err := r.db.Beginx()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
query := `UPDATE recurring_expenses SET description = $1, amount_cents = $2, type = $3, payment_method_id = $4, frequency = $5, start_date = $6, end_date = $7, next_occurrence = $8, updated_at = $9 WHERE id = $10;`
|
||||
_, err = tx.Exec(query, re.Description, re.AmountCents, re.Type, re.PaymentMethodID, re.Frequency, re.StartDate, re.EndDate, re.NextOccurrence, re.UpdatedAt, re.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = tx.Exec(`DELETE FROM recurring_expense_tags WHERE recurring_expense_id = $1;`, re.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(tagIDs) > 0 {
|
||||
tagQuery := `INSERT INTO recurring_expense_tags (recurring_expense_id, tag_id) VALUES ($1, $2);`
|
||||
for _, tagID := range tagIDs {
|
||||
if _, err := tx.Exec(tagQuery, re.ID, tagID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (r *recurringExpenseRepository) Delete(id string) error {
|
||||
_, err := r.db.Exec(`DELETE FROM recurring_expenses WHERE id = $1;`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *recurringExpenseRepository) SetActive(id string, active bool) error {
|
||||
_, err := r.db.Exec(`UPDATE recurring_expenses SET is_active = $1, updated_at = $2 WHERE id = $3;`, active, time.Now(), id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *recurringExpenseRepository) GetDueRecurrences(now time.Time) ([]*model.RecurringExpense, error) {
|
||||
var results []*model.RecurringExpense
|
||||
query := `SELECT * FROM recurring_expenses WHERE is_active = true AND next_occurrence <= $1;`
|
||||
err := r.db.Select(&results, query, now)
|
||||
return results, err
|
||||
}
|
||||
|
||||
func (r *recurringExpenseRepository) GetDueRecurrencesForSpace(spaceID string, now time.Time) ([]*model.RecurringExpense, error) {
|
||||
var results []*model.RecurringExpense
|
||||
query := `SELECT * FROM recurring_expenses WHERE is_active = true AND space_id = $1 AND next_occurrence <= $2;`
|
||||
err := r.db.Select(&results, query, spaceID, now)
|
||||
return results, err
|
||||
}
|
||||
|
||||
func (r *recurringExpenseRepository) UpdateNextOccurrence(id string, next time.Time) error {
|
||||
_, err := r.db.Exec(`UPDATE recurring_expenses SET next_occurrence = $1, updated_at = $2 WHERE id = $3;`, next, time.Now(), id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *recurringExpenseRepository) Deactivate(id string) error {
|
||||
return r.SetActive(id, false)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue