chore: massive reset

This commit is contained in:
juancwu 2026-04-06 17:51:59 +00:00
commit df164ab0f4
96 changed files with 198 additions and 15405 deletions

View file

@ -1,168 +0,0 @@
package repository
import (
"database/sql"
"errors"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"github.com/jmoiron/sqlx"
"github.com/shopspring/decimal"
)
var (
ErrBudgetNotFound = errors.New("budget not found")
)
type BudgetRepository interface {
Create(budget *model.Budget, tagIDs []string) error
GetByID(id string) (*model.Budget, error)
GetBySpaceID(spaceID string) ([]*model.Budget, error)
GetSpentForBudget(spaceID string, tagIDs []string, periodStart, periodEnd time.Time) (decimal.Decimal, error)
GetTagsByBudgetIDs(budgetIDs []string) (map[string][]*model.Tag, error)
Update(budget *model.Budget, tagIDs []string) 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, tagIDs []string) error {
return WithTx(r.db, func(tx *sqlx.Tx) error {
query := `INSERT INTO budgets (id, space_id, amount, period, start_date, end_date, is_active, created_by, created_at, updated_at, amount_cents)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 0);`
if _, err := tx.Exec(query, budget.ID, budget.SpaceID, budget.Amount, budget.Period, budget.StartDate, budget.EndDate, budget.IsActive, budget.CreatedBy, budget.CreatedAt, budget.UpdatedAt); err != nil {
return err
}
if len(tagIDs) > 0 {
tagQuery := `INSERT INTO budget_tags (budget_id, tag_id) VALUES ($1, $2);`
for _, tagID := range tagIDs {
if _, err := tx.Exec(tagQuery, budget.ID, tagID); err != nil {
return err
}
}
}
return nil
})
}
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 string, tagIDs []string, periodStart, periodEnd time.Time) (decimal.Decimal, error) {
if len(tagIDs) == 0 {
return decimal.Zero, nil
}
query, args, err := sqlx.In(`
SELECT COALESCE(SUM(CAST(e.amount AS DECIMAL)), 0)
FROM expenses e
WHERE e.space_id = ? AND e.type = 'expense' AND e.date >= ? AND e.date <= ?
AND EXISTS (SELECT 1 FROM expense_tags et WHERE et.expense_id = e.id AND et.tag_id IN (?))
`, spaceID, periodStart, periodEnd, tagIDs)
if err != nil {
return decimal.Zero, err
}
query = r.db.Rebind(query)
var spent decimal.Decimal
err = r.db.Get(&spent, query, args...)
return spent, err
}
func (r *budgetRepository) GetTagsByBudgetIDs(budgetIDs []string) (map[string][]*model.Tag, error) {
if len(budgetIDs) == 0 {
return make(map[string][]*model.Tag), nil
}
type row struct {
BudgetID string `db:"budget_id"`
ID string `db:"id"`
SpaceID string `db:"space_id"`
Name string `db:"name"`
Color *string `db:"color"`
}
query, args, err := sqlx.In(`
SELECT bt.budget_id, t.id, t.space_id, t.name, t.color
FROM budget_tags bt
JOIN tags t ON bt.tag_id = t.id
WHERE bt.budget_id IN (?)
ORDER BY t.name;
`, budgetIDs)
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.BudgetID] = append(result[rw.BudgetID], &model.Tag{
ID: rw.ID,
SpaceID: rw.SpaceID,
Name: rw.Name,
Color: rw.Color,
})
}
return result, nil
}
func (r *budgetRepository) Update(budget *model.Budget, tagIDs []string) error {
return WithTx(r.db, func(tx *sqlx.Tx) error {
query := `UPDATE budgets SET amount = $1, period = $2, start_date = $3, end_date = $4, is_active = $5, updated_at = $6 WHERE id = $7;`
if _, err := tx.Exec(query, budget.Amount, budget.Period, budget.StartDate, budget.EndDate, budget.IsActive, budget.UpdatedAt, budget.ID); err != nil {
return err
}
if _, err := tx.Exec(`DELETE FROM budget_tags WHERE budget_id = $1;`, budget.ID); err != nil {
return err
}
if len(tagIDs) > 0 {
tagQuery := `INSERT INTO budget_tags (budget_id, tag_id) VALUES ($1, $2);`
for _, tagID := range tagIDs {
if _, err := tx.Exec(tagQuery, budget.ID, tagID); err != nil {
return err
}
}
}
return nil
})
}
func (r *budgetRepository) Delete(id string) error {
result, err := r.db.Exec(`DELETE FROM budgets WHERE id = $1;`, id)
if err != nil {
return err
}
rows, err := result.RowsAffected()
if err == nil && rows == 0 {
return ErrBudgetNotFound
}
return err
}

View file

@ -1,347 +0,0 @@
package repository
import (
"database/sql"
"errors"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"github.com/jmoiron/sqlx"
"github.com/shopspring/decimal"
)
var (
ErrExpenseNotFound = errors.New("expense not found")
)
type ExpenseRepository interface {
Create(expense *model.Expense, tagIDs []string, itemIDs []string) error
GetByID(id string) (*model.Expense, error)
GetBySpaceID(spaceID string) ([]*model.Expense, error)
GetBySpaceIDPaginated(spaceID string, limit, offset int) ([]*model.Expense, error)
CountBySpaceID(spaceID string) (int, error)
GetExpensesByTag(spaceID string, fromDate, toDate time.Time) ([]*model.TagExpenseSummary, error)
GetTagsByExpenseIDs(expenseIDs []string) (map[string][]*model.Tag, error)
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) (decimal.Decimal, decimal.Decimal, error)
GetExpensesByPaymentMethod(spaceID string, from, to time.Time) ([]*model.PaymentMethodExpenseSummary, error)
}
type expenseRepository struct {
db *sqlx.DB
}
func NewExpenseRepository(db *sqlx.DB) ExpenseRepository {
return &expenseRepository{db: db}
}
func (r *expenseRepository) Create(expense *model.Expense, tagIDs []string, itemIDs []string) error {
return WithTx(r.db, func(tx *sqlx.Tx) error {
queryExpense := `INSERT INTO expenses (id, space_id, created_by, description, amount, type, date, payment_method_id, recurring_expense_id, created_at, updated_at, amount_cents)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, 0);`
if _, err := tx.Exec(queryExpense, expense.ID, expense.SpaceID, expense.CreatedBy, expense.Description, expense.Amount, expense.Type, expense.Date, expense.PaymentMethodID, expense.RecurringExpenseID, expense.CreatedAt, expense.UpdatedAt); err != nil {
return err
}
if len(tagIDs) > 0 {
queryTags := `INSERT INTO expense_tags (expense_id, tag_id) VALUES ($1, $2);`
for _, tagID := range tagIDs {
if _, err := tx.Exec(queryTags, expense.ID, tagID); err != nil {
return err
}
}
}
if len(itemIDs) > 0 {
queryItems := `INSERT INTO expense_items (expense_id, item_id) VALUES ($1, $2);`
for _, itemID := range itemIDs {
if _, err := tx.Exec(queryItems, expense.ID, itemID); err != nil {
return err
}
}
}
return nil
})
}
func (r *expenseRepository) GetByID(id string) (*model.Expense, error) {
expense := &model.Expense{}
query := `SELECT * FROM expenses WHERE id = $1;`
err := r.db.Get(expense, query, id)
if err == sql.ErrNoRows {
return nil, ErrExpenseNotFound
}
return expense, err
}
func (r *expenseRepository) GetBySpaceID(spaceID string) ([]*model.Expense, error) {
var expenses []*model.Expense
query := `SELECT * FROM expenses WHERE space_id = $1 ORDER BY date DESC, created_at DESC;`
err := r.db.Select(&expenses, query, spaceID)
if err != nil {
return nil, err
}
return expenses, nil
}
func (r *expenseRepository) GetBySpaceIDPaginated(spaceID string, limit, offset int) ([]*model.Expense, error) {
var expenses []*model.Expense
query := `SELECT * FROM expenses WHERE space_id = $1 ORDER BY date DESC, created_at DESC LIMIT $2 OFFSET $3;`
err := r.db.Select(&expenses, query, spaceID, limit, offset)
if err != nil {
return nil, err
}
return expenses, nil
}
func (r *expenseRepository) CountBySpaceID(spaceID string) (int, error) {
var count int
err := r.db.Get(&count, `SELECT COUNT(*) FROM expenses WHERE space_id = $1;`, spaceID)
return count, err
}
func (r *expenseRepository) GetExpensesByTag(spaceID string, fromDate, toDate time.Time) ([]*model.TagExpenseSummary, error) {
var summaries []*model.TagExpenseSummary
query := `
SELECT
t.id as tag_id,
t.name as tag_name,
t.color as tag_color,
SUM(CAST(e.amount AS DECIMAL)) as total_amount
FROM expenses e
JOIN expense_tags et ON e.id = et.expense_id
JOIN tags t ON et.tag_id = t.id
WHERE e.space_id = $1 AND e.type = 'expense' AND e.date >= $2 AND e.date <= $3
GROUP BY t.id, t.name, t.color
ORDER BY total_amount DESC;
`
err := r.db.Select(&summaries, query, spaceID, fromDate, toDate)
if err != nil {
return nil, err
}
return summaries, nil
}
func (r *expenseRepository) GetTagsByExpenseIDs(expenseIDs []string) (map[string][]*model.Tag, error) {
if len(expenseIDs) == 0 {
return make(map[string][]*model.Tag), nil
}
type row struct {
ExpenseID string `db:"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 et.expense_id, t.id, t.space_id, t.name, t.color
FROM expense_tags et
JOIN tags t ON et.tag_id = t.id
WHERE et.expense_id IN (?)
ORDER BY t.name;
`, expenseIDs)
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.ExpenseID] = append(result[rw.ExpenseID], &model.Tag{
ID: rw.ID,
SpaceID: rw.SpaceID,
Name: rw.Name,
Color: rw.Color,
})
}
return result, nil
}
func (r *expenseRepository) GetPaymentMethodsByExpenseIDs(expenseIDs []string) (map[string]*model.PaymentMethod, error) {
if len(expenseIDs) == 0 {
return make(map[string]*model.PaymentMethod), nil
}
type row struct {
ExpenseID string `db:"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 e.id AS expense_id, pm.id, pm.space_id, pm.name, pm.type, pm.last_four
FROM expenses e
JOIN payment_methods pm ON e.payment_method_id = pm.id
WHERE e.id IN (?) AND e.payment_method_id IS NOT NULL;
`, expenseIDs)
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.ExpenseID] = &model.PaymentMethod{
ID: rw.ID,
SpaceID: rw.SpaceID,
Name: rw.Name,
Type: rw.Type,
LastFour: rw.LastFour,
}
}
return result, nil
}
func (r *expenseRepository) Update(expense *model.Expense, tagIDs []string) error {
return WithTx(r.db, func(tx *sqlx.Tx) error {
query := `UPDATE expenses SET description = $1, amount = $2, type = $3, date = $4, payment_method_id = $5, updated_at = $6 WHERE id = $7;`
if _, err := tx.Exec(query, expense.Description, expense.Amount, expense.Type, expense.Date, expense.PaymentMethodID, expense.UpdatedAt, expense.ID); err != nil {
return err
}
if _, err := tx.Exec(`DELETE FROM expense_tags WHERE expense_id = $1;`, expense.ID); err != nil {
return err
}
if len(tagIDs) > 0 {
insertTag := `INSERT INTO expense_tags (expense_id, tag_id) VALUES ($1, $2);`
for _, tagID := range tagIDs {
if _, err := tx.Exec(insertTag, expense.ID, tagID); err != nil {
return err
}
}
}
return nil
})
}
func (r *expenseRepository) Delete(id string) error {
result, err := r.db.Exec(`DELETE FROM expenses WHERE id = $1;`, id)
if err != nil {
return err
}
rows, err := result.RowsAffected()
if err == nil && rows == 0 {
return ErrExpenseNotFound
}
return err
}
func (r *expenseRepository) GetDailySpending(spaceID string, from, to time.Time) ([]*model.DailySpending, error) {
var results []*model.DailySpending
query := `
SELECT date, SUM(CAST(amount AS DECIMAL)) as total
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
var query string
if r.db.DriverName() == "sqlite" {
query = `
SELECT strftime('%Y-%m', date) as month, SUM(CAST(amount AS DECIMAL)) as total
FROM expenses
WHERE space_id = $1 AND type = 'expense' AND date >= $2 AND date <= $3
GROUP BY strftime('%Y-%m', date)
ORDER BY month ASC;`
} else {
query = `
SELECT TO_CHAR(date, 'YYYY-MM') as month, SUM(CAST(amount AS DECIMAL)) as total
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 CAST(amount AS DECIMAL) 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) (decimal.Decimal, decimal.Decimal, error) {
type summary struct {
Type string `db:"type"`
Total decimal.Decimal `db:"total"`
}
var results []summary
query := `
SELECT type, COALESCE(SUM(CAST(amount AS DECIMAL)), 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 decimal.Zero, decimal.Zero, err
}
income := decimal.Zero
expenseTotal := decimal.Zero
for _, r := range results {
if r.Type == "topup" {
income = r.Total
} else if r.Type == "expense" {
expenseTotal = r.Total
}
}
return income, expenseTotal, nil
}
func (r *expenseRepository) GetExpensesByPaymentMethod(spaceID string, from, to time.Time) ([]*model.PaymentMethodExpenseSummary, error) {
var summaries []*model.PaymentMethodExpenseSummary
query := `
SELECT COALESCE(pm.id, 'cash') as payment_method_id,
COALESCE(pm.name, 'Cash') as payment_method_name,
COALESCE(pm.type, 'cash') as payment_method_type,
SUM(CAST(e.amount AS DECIMAL)) as total_amount
FROM expenses e
LEFT JOIN payment_methods pm ON e.payment_method_id = pm.id
WHERE e.space_id = $1 AND e.type = 'expense' AND e.date >= $2 AND e.date <= $3
GROUP BY pm.id, pm.name, pm.type
ORDER BY total_amount DESC;
`
err := r.db.Select(&summaries, query, spaceID, from, to)
if err != nil {
return nil, err
}
return summaries, nil
}

View file

@ -1,246 +0,0 @@
package repository
import (
"testing"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/testutil"
"github.com/google/uuid"
"github.com/shopspring/decimal"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestExpenseRepository_Create(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewExpenseRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
tag := testutil.CreateTestTag(t, dbi.DB, space.ID, "Food", nil)
now := time.Now()
expense := &model.Expense{
ID: uuid.NewString(),
SpaceID: space.ID,
CreatedBy: user.ID,
Description: "Lunch",
Amount: decimal.RequireFromString("15.49"),
Type: model.ExpenseTypeExpense,
Date: now,
CreatedAt: now,
UpdatedAt: now,
}
err := repo.Create(expense, []string{tag.ID}, nil)
require.NoError(t, err)
fetched, err := repo.GetByID(expense.ID)
require.NoError(t, err)
assert.Equal(t, expense.ID, fetched.ID)
assert.Equal(t, "Lunch", fetched.Description)
assert.True(t, decimal.RequireFromString("15.49").Equal(fetched.Amount))
assert.Equal(t, model.ExpenseTypeExpense, fetched.Type)
})
}
func TestExpenseRepository_GetBySpaceIDPaginated(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewExpenseRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
testutil.CreateTestExpense(t, dbi.DB, space.ID, user.ID, "Expense 1", decimal.RequireFromString("10.75"), model.ExpenseTypeExpense)
testutil.CreateTestExpense(t, dbi.DB, space.ID, user.ID, "Expense 2", decimal.RequireFromString("20.50"), model.ExpenseTypeExpense)
testutil.CreateTestExpense(t, dbi.DB, space.ID, user.ID, "Expense 3", decimal.RequireFromString("30.25"), model.ExpenseTypeExpense)
expenses, err := repo.GetBySpaceIDPaginated(space.ID, 2, 0)
require.NoError(t, err)
assert.Len(t, expenses, 2)
})
}
func TestExpenseRepository_CountBySpaceID(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewExpenseRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
testutil.CreateTestExpense(t, dbi.DB, space.ID, user.ID, "Expense 1", decimal.RequireFromString("10.75"), model.ExpenseTypeExpense)
testutil.CreateTestExpense(t, dbi.DB, space.ID, user.ID, "Expense 2", decimal.RequireFromString("20.50"), model.ExpenseTypeExpense)
count, err := repo.CountBySpaceID(space.ID)
require.NoError(t, err)
assert.Equal(t, 2, count)
})
}
func TestExpenseRepository_GetTagsByExpenseIDs(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewExpenseRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
tag := testutil.CreateTestTag(t, dbi.DB, space.ID, "Groceries", nil)
now := time.Now()
expense := &model.Expense{
ID: uuid.NewString(),
SpaceID: space.ID,
CreatedBy: user.ID,
Description: "Weekly groceries",
Amount: decimal.RequireFromString("49.99"),
Type: model.ExpenseTypeExpense,
Date: now,
CreatedAt: now,
UpdatedAt: now,
}
err := repo.Create(expense, []string{tag.ID}, nil)
require.NoError(t, err)
tagMap, err := repo.GetTagsByExpenseIDs([]string{expense.ID})
require.NoError(t, err)
require.Contains(t, tagMap, expense.ID)
require.Len(t, tagMap[expense.ID], 1)
assert.Equal(t, tag.ID, tagMap[expense.ID][0].ID)
assert.Equal(t, "Groceries", tagMap[expense.ID][0].Name)
})
}
func TestExpenseRepository_GetPaymentMethodsByExpenseIDs(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewExpenseRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
method := testutil.CreateTestPaymentMethod(t, dbi.DB, space.ID, "Visa", model.PaymentMethodTypeCredit, user.ID)
now := time.Now()
expense := &model.Expense{
ID: uuid.NewString(),
SpaceID: space.ID,
CreatedBy: user.ID,
Description: "Online purchase",
Amount: decimal.RequireFromString("29.95"),
Type: model.ExpenseTypeExpense,
Date: now,
PaymentMethodID: &method.ID,
CreatedAt: now,
UpdatedAt: now,
}
err := repo.Create(expense, nil, nil)
require.NoError(t, err)
methodMap, err := repo.GetPaymentMethodsByExpenseIDs([]string{expense.ID})
require.NoError(t, err)
require.Contains(t, methodMap, expense.ID)
assert.Equal(t, method.ID, methodMap[expense.ID].ID)
assert.Equal(t, "Visa", methodMap[expense.ID].Name)
assert.Equal(t, model.PaymentMethodTypeCredit, methodMap[expense.ID].Type)
})
}
func TestExpenseRepository_GetExpensesByTag(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewExpenseRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
color := "#ff0000"
tag := testutil.CreateTestTag(t, dbi.DB, space.ID, "Food", &color)
now := time.Now()
fromDate := now.Add(-24 * time.Hour)
toDate := now.Add(24 * time.Hour)
expense1 := &model.Expense{
ID: uuid.NewString(),
SpaceID: space.ID,
CreatedBy: user.ID,
Description: "Lunch",
Amount: decimal.RequireFromString("15.49"),
Type: model.ExpenseTypeExpense,
Date: now,
CreatedAt: now,
UpdatedAt: now,
}
err := repo.Create(expense1, []string{tag.ID}, nil)
require.NoError(t, err)
expense2 := &model.Expense{
ID: uuid.NewString(),
SpaceID: space.ID,
CreatedBy: user.ID,
Description: "Dinner",
Amount: decimal.RequireFromString("24.52"),
Type: model.ExpenseTypeExpense,
Date: now,
CreatedAt: now,
UpdatedAt: now,
}
err = repo.Create(expense2, []string{tag.ID}, nil)
require.NoError(t, err)
summaries, err := repo.GetExpensesByTag(space.ID, fromDate, toDate)
require.NoError(t, err)
require.Len(t, summaries, 1)
assert.Equal(t, tag.ID, summaries[0].TagID)
assert.Equal(t, "Food", summaries[0].TagName)
assert.True(t, decimal.RequireFromString("40.01").Equal(summaries[0].TotalAmount))
})
}
func TestExpenseRepository_Update(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewExpenseRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
tag1 := testutil.CreateTestTag(t, dbi.DB, space.ID, "Tag A", nil)
tag2 := testutil.CreateTestTag(t, dbi.DB, space.ID, "Tag B", nil)
now := time.Now()
expense := &model.Expense{
ID: uuid.NewString(),
SpaceID: space.ID,
CreatedBy: user.ID,
Description: "Original",
Amount: decimal.RequireFromString("10.75"),
Type: model.ExpenseTypeExpense,
Date: now,
CreatedAt: now,
UpdatedAt: now,
}
err := repo.Create(expense, []string{tag1.ID}, nil)
require.NoError(t, err)
expense.Description = "Updated"
expense.UpdatedAt = time.Now()
err = repo.Update(expense, []string{tag2.ID})
require.NoError(t, err)
fetched, err := repo.GetByID(expense.ID)
require.NoError(t, err)
assert.Equal(t, "Updated", fetched.Description)
tagMap, err := repo.GetTagsByExpenseIDs([]string{expense.ID})
require.NoError(t, err)
require.Len(t, tagMap[expense.ID], 1)
assert.Equal(t, tag2.ID, tagMap[expense.ID][0].ID)
})
}
func TestExpenseRepository_Delete(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewExpenseRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
expense := testutil.CreateTestExpense(t, dbi.DB, space.ID, user.ID, "To Delete", decimal.RequireFromString("4.99"), model.ExpenseTypeExpense)
err := repo.Delete(expense.ID)
require.NoError(t, err)
_, err = repo.GetByID(expense.ID)
assert.ErrorIs(t, err, ErrExpenseNotFound)
})
}

View file

@ -1,109 +0,0 @@
package repository
import (
"database/sql"
"errors"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"github.com/jmoiron/sqlx"
)
var (
ErrListItemNotFound = errors.New("list item not found")
)
type ListItemRepository interface {
Create(item *model.ListItem) error
GetByID(id string) (*model.ListItem, error)
GetByListID(listID string) ([]*model.ListItem, error)
GetByListIDPaginated(listID string, limit, offset int) ([]*model.ListItem, error)
CountByListID(listID string) (int, error)
Update(item *model.ListItem) error
Delete(id string) error
DeleteByListID(listID string) error
}
type listItemRepository struct {
db *sqlx.DB
}
func NewListItemRepository(db *sqlx.DB) ListItemRepository {
return &listItemRepository{db: db}
}
func (r *listItemRepository) Create(item *model.ListItem) error {
query := `INSERT INTO list_items (id, list_id, name, is_checked, created_by, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7);`
_, err := r.db.Exec(query, item.ID, item.ListID, item.Name, item.IsChecked, item.CreatedBy, item.CreatedAt, item.UpdatedAt)
return err
}
func (r *listItemRepository) GetByID(id string) (*model.ListItem, error) {
item := &model.ListItem{}
query := `SELECT * FROM list_items WHERE id = $1;`
err := r.db.Get(item, query, id)
if err == sql.ErrNoRows {
return nil, ErrListItemNotFound
}
return item, err
}
func (r *listItemRepository) GetByListID(listID string) ([]*model.ListItem, error) {
var items []*model.ListItem
query := `SELECT * FROM list_items WHERE list_id = $1 ORDER BY created_at ASC;`
err := r.db.Select(&items, query, listID)
if err != nil {
return nil, err
}
return items, nil
}
func (r *listItemRepository) GetByListIDPaginated(listID string, limit, offset int) ([]*model.ListItem, error) {
var items []*model.ListItem
query := `SELECT * FROM list_items WHERE list_id = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3;`
err := r.db.Select(&items, query, listID, limit, offset)
if err != nil {
return nil, err
}
return items, nil
}
func (r *listItemRepository) CountByListID(listID string) (int, error) {
var count int
query := `SELECT COUNT(*) FROM list_items WHERE list_id = $1;`
err := r.db.Get(&count, query, listID)
return count, err
}
func (r *listItemRepository) Update(item *model.ListItem) error {
item.UpdatedAt = time.Now()
query := `UPDATE list_items SET name = $1, is_checked = $2, updated_at = $3 WHERE id = $4;`
result, err := r.db.Exec(query, item.Name, item.IsChecked, item.UpdatedAt, item.ID)
if err != nil {
return err
}
rows, err := result.RowsAffected()
if err == nil && rows == 0 {
return ErrListItemNotFound
}
return err
}
func (r *listItemRepository) Delete(id string) error {
query := `DELETE FROM list_items WHERE id = $1;`
result, err := r.db.Exec(query, id)
if err != nil {
return err
}
rows, err := result.RowsAffected()
if err == nil && rows == 0 {
return ErrListItemNotFound
}
return err
}
func (r *listItemRepository) DeleteByListID(listID string) error {
query := `DELETE FROM list_items WHERE list_id = $1;`
_, err := r.db.Exec(query, listID)
return err
}

View file

@ -1,161 +0,0 @@
package repository
import (
"testing"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/testutil"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestListItemRepository_Create(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewListItemRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
list := testutil.CreateTestShoppingList(t, dbi.DB, space.ID, "Test List")
now := time.Now()
item := &model.ListItem{
ID: uuid.NewString(),
ListID: list.ID,
Name: "Apples",
IsChecked: false,
CreatedBy: user.ID,
CreatedAt: now,
UpdatedAt: now,
}
err := repo.Create(item)
require.NoError(t, err)
fetched, err := repo.GetByID(item.ID)
require.NoError(t, err)
assert.Equal(t, item.ID, fetched.ID)
assert.Equal(t, list.ID, fetched.ListID)
assert.Equal(t, "Apples", fetched.Name)
assert.False(t, fetched.IsChecked)
assert.Equal(t, user.ID, fetched.CreatedBy)
})
}
func TestListItemRepository_GetByListID(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewListItemRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
list := testutil.CreateTestShoppingList(t, dbi.DB, space.ID, "Test List")
item1 := testutil.CreateTestListItem(t, dbi.DB, list.ID, "Item A", user.ID)
time.Sleep(10 * time.Millisecond)
item2 := testutil.CreateTestListItem(t, dbi.DB, list.ID, "Item B", user.ID)
items, err := repo.GetByListID(list.ID)
require.NoError(t, err)
require.Len(t, items, 2)
// Ordered by created_at ASC, so item1 should be first.
assert.Equal(t, item1.ID, items[0].ID)
assert.Equal(t, item2.ID, items[1].ID)
})
}
func TestListItemRepository_GetByListIDPaginated(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewListItemRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
list := testutil.CreateTestShoppingList(t, dbi.DB, space.ID, "Test List")
testutil.CreateTestListItem(t, dbi.DB, list.ID, "Item A", user.ID)
time.Sleep(10 * time.Millisecond)
testutil.CreateTestListItem(t, dbi.DB, list.ID, "Item B", user.ID)
time.Sleep(10 * time.Millisecond)
testutil.CreateTestListItem(t, dbi.DB, list.ID, "Item C", user.ID)
items, err := repo.GetByListIDPaginated(list.ID, 2, 0)
require.NoError(t, err)
assert.Len(t, items, 2)
count, err := repo.CountByListID(list.ID)
require.NoError(t, err)
assert.Equal(t, 3, count)
})
}
func TestListItemRepository_CountByListID(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewListItemRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
list := testutil.CreateTestShoppingList(t, dbi.DB, space.ID, "Test List")
testutil.CreateTestListItem(t, dbi.DB, list.ID, "Item A", user.ID)
testutil.CreateTestListItem(t, dbi.DB, list.ID, "Item B", user.ID)
testutil.CreateTestListItem(t, dbi.DB, list.ID, "Item C", user.ID)
count, err := repo.CountByListID(list.ID)
require.NoError(t, err)
assert.Equal(t, 3, count)
})
}
func TestListItemRepository_Update(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewListItemRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
list := testutil.CreateTestShoppingList(t, dbi.DB, space.ID, "Test List")
item := testutil.CreateTestListItem(t, dbi.DB, list.ID, "Original", user.ID)
item.Name = "Updated"
item.IsChecked = true
err := repo.Update(item)
require.NoError(t, err)
fetched, err := repo.GetByID(item.ID)
require.NoError(t, err)
assert.Equal(t, "Updated", fetched.Name)
assert.True(t, fetched.IsChecked)
})
}
func TestListItemRepository_Delete(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewListItemRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
list := testutil.CreateTestShoppingList(t, dbi.DB, space.ID, "Test List")
item := testutil.CreateTestListItem(t, dbi.DB, list.ID, "To Delete", user.ID)
err := repo.Delete(item.ID)
require.NoError(t, err)
_, err = repo.GetByID(item.ID)
assert.ErrorIs(t, err, ErrListItemNotFound)
})
}
func TestListItemRepository_DeleteByListID(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewListItemRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
list := testutil.CreateTestShoppingList(t, dbi.DB, space.ID, "Test List")
testutil.CreateTestListItem(t, dbi.DB, list.ID, "Item A", user.ID)
testutil.CreateTestListItem(t, dbi.DB, list.ID, "Item B", user.ID)
err := repo.DeleteByListID(list.ID)
require.NoError(t, err)
items, err := repo.GetByListID(list.ID)
require.NoError(t, err)
assert.Empty(t, items)
})
}

View file

@ -1,108 +0,0 @@
package repository
import (
"database/sql"
"errors"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"github.com/jmoiron/sqlx"
"github.com/shopspring/decimal"
)
var (
ErrLoanNotFound = errors.New("loan not found")
)
type LoanRepository interface {
Create(loan *model.Loan) error
GetByID(id string) (*model.Loan, error)
GetBySpaceID(spaceID string) ([]*model.Loan, error)
GetBySpaceIDPaginated(spaceID string, limit, offset int) ([]*model.Loan, error)
CountBySpaceID(spaceID string) (int, error)
Update(loan *model.Loan) error
Delete(id string) error
SetPaidOff(id string, paidOff bool) error
GetTotalPaidForLoan(loanID string) (decimal.Decimal, error)
GetReceiptCountForLoan(loanID string) (int, error)
}
type loanRepository struct {
db *sqlx.DB
}
func NewLoanRepository(db *sqlx.DB) LoanRepository {
return &loanRepository{db: db}
}
func (r *loanRepository) Create(loan *model.Loan) error {
query := `INSERT INTO loans (id, space_id, name, description, original_amount, interest_rate_bps, start_date, end_date, is_paid_off, created_by, created_at, updated_at, original_amount_cents)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, 0);`
_, err := r.db.Exec(query, loan.ID, loan.SpaceID, loan.Name, loan.Description, loan.OriginalAmount, loan.InterestRateBps, loan.StartDate, loan.EndDate, loan.IsPaidOff, loan.CreatedBy, loan.CreatedAt, loan.UpdatedAt)
return err
}
func (r *loanRepository) GetByID(id string) (*model.Loan, error) {
loan := &model.Loan{}
query := `SELECT * FROM loans WHERE id = $1;`
err := r.db.Get(loan, query, id)
if err == sql.ErrNoRows {
return nil, ErrLoanNotFound
}
return loan, err
}
func (r *loanRepository) GetBySpaceID(spaceID string) ([]*model.Loan, error) {
var loans []*model.Loan
query := `SELECT * FROM loans WHERE space_id = $1 ORDER BY is_paid_off ASC, created_at DESC;`
err := r.db.Select(&loans, query, spaceID)
return loans, err
}
func (r *loanRepository) GetBySpaceIDPaginated(spaceID string, limit, offset int) ([]*model.Loan, error) {
var loans []*model.Loan
query := `SELECT * FROM loans WHERE space_id = $1 ORDER BY is_paid_off ASC, created_at DESC LIMIT $2 OFFSET $3;`
err := r.db.Select(&loans, query, spaceID, limit, offset)
return loans, err
}
func (r *loanRepository) CountBySpaceID(spaceID string) (int, error) {
var count int
err := r.db.Get(&count, `SELECT COUNT(*) FROM loans WHERE space_id = $1;`, spaceID)
return count, err
}
func (r *loanRepository) Update(loan *model.Loan) error {
query := `UPDATE loans SET name = $1, description = $2, original_amount = $3, interest_rate_bps = $4, start_date = $5, end_date = $6, updated_at = $7 WHERE id = $8;`
result, err := r.db.Exec(query, loan.Name, loan.Description, loan.OriginalAmount, loan.InterestRateBps, loan.StartDate, loan.EndDate, loan.UpdatedAt, loan.ID)
if err != nil {
return err
}
rows, err := result.RowsAffected()
if err == nil && rows == 0 {
return ErrLoanNotFound
}
return err
}
func (r *loanRepository) Delete(id string) error {
_, err := r.db.Exec(`DELETE FROM loans WHERE id = $1;`, id)
return err
}
func (r *loanRepository) SetPaidOff(id string, paidOff bool) error {
_, err := r.db.Exec(`UPDATE loans SET is_paid_off = $1, updated_at = $2 WHERE id = $3;`, paidOff, time.Now(), id)
return err
}
func (r *loanRepository) GetTotalPaidForLoan(loanID string) (decimal.Decimal, error) {
var total decimal.Decimal
err := r.db.Get(&total, `SELECT COALESCE(SUM(CAST(total_amount AS DECIMAL)), 0) FROM receipts WHERE loan_id = $1;`, loanID)
return total, err
}
func (r *loanRepository) GetReceiptCountForLoan(loanID string) (int, error) {
var count int
err := r.db.Get(&count, `SELECT COUNT(*) FROM receipts WHERE loan_id = $1;`, loanID)
return count, err
}

View file

@ -1,166 +0,0 @@
package repository
import (
"database/sql"
"errors"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"github.com/jmoiron/sqlx"
"github.com/shopspring/decimal"
)
var (
ErrMoneyAccountNotFound = errors.New("money account not found")
ErrTransferNotFound = errors.New("account transfer not found")
)
type MoneyAccountRepository interface {
Create(account *model.MoneyAccount) error
GetByID(id string) (*model.MoneyAccount, error)
GetBySpaceID(spaceID string) ([]*model.MoneyAccount, error)
Update(account *model.MoneyAccount) error
Delete(id string) error
CreateTransfer(transfer *model.AccountTransfer) error
GetTransfersByAccountID(accountID string) ([]*model.AccountTransfer, error)
DeleteTransfer(id string) error
GetAccountBalance(accountID string) (decimal.Decimal, error)
GetTotalAllocatedForSpace(spaceID string) (decimal.Decimal, error)
GetTransfersBySpaceIDPaginated(spaceID string, limit, offset int) ([]*model.AccountTransferWithAccount, error)
CountTransfersBySpaceID(spaceID string) (int, error)
}
type moneyAccountRepository struct {
db *sqlx.DB
}
func NewMoneyAccountRepository(db *sqlx.DB) MoneyAccountRepository {
return &moneyAccountRepository{db: db}
}
func (r *moneyAccountRepository) Create(account *model.MoneyAccount) error {
query := `INSERT INTO money_accounts (id, space_id, name, created_by, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6);`
_, err := r.db.Exec(query, account.ID, account.SpaceID, account.Name, account.CreatedBy, account.CreatedAt, account.UpdatedAt)
return err
}
func (r *moneyAccountRepository) GetByID(id string) (*model.MoneyAccount, error) {
account := &model.MoneyAccount{}
query := `SELECT * FROM money_accounts WHERE id = $1;`
err := r.db.Get(account, query, id)
if err == sql.ErrNoRows {
return nil, ErrMoneyAccountNotFound
}
return account, err
}
func (r *moneyAccountRepository) GetBySpaceID(spaceID string) ([]*model.MoneyAccount, error) {
var accounts []*model.MoneyAccount
query := `SELECT * FROM money_accounts WHERE space_id = $1 ORDER BY created_at DESC;`
err := r.db.Select(&accounts, query, spaceID)
if err != nil {
return nil, err
}
return accounts, nil
}
func (r *moneyAccountRepository) Update(account *model.MoneyAccount) error {
account.UpdatedAt = time.Now()
query := `UPDATE money_accounts SET name = $1, updated_at = $2 WHERE id = $3;`
result, err := r.db.Exec(query, account.Name, account.UpdatedAt, account.ID)
if err != nil {
return err
}
rows, err := result.RowsAffected()
if err == nil && rows == 0 {
return ErrMoneyAccountNotFound
}
return err
}
func (r *moneyAccountRepository) Delete(id string) error {
query := `DELETE FROM money_accounts WHERE id = $1;`
result, err := r.db.Exec(query, id)
if err != nil {
return err
}
rows, err := result.RowsAffected()
if err == nil && rows == 0 {
return ErrMoneyAccountNotFound
}
return err
}
func (r *moneyAccountRepository) CreateTransfer(transfer *model.AccountTransfer) error {
query := `INSERT INTO account_transfers (id, account_id, amount, direction, note, recurring_deposit_id, created_by, created_at, amount_cents) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 0);`
_, err := r.db.Exec(query, transfer.ID, transfer.AccountID, transfer.Amount, transfer.Direction, transfer.Note, transfer.RecurringDepositID, transfer.CreatedBy, transfer.CreatedAt)
return err
}
func (r *moneyAccountRepository) GetTransfersByAccountID(accountID string) ([]*model.AccountTransfer, error) {
var transfers []*model.AccountTransfer
query := `SELECT * FROM account_transfers WHERE account_id = $1 ORDER BY created_at DESC;`
err := r.db.Select(&transfers, query, accountID)
if err != nil {
return nil, err
}
return transfers, nil
}
func (r *moneyAccountRepository) DeleteTransfer(id string) error {
query := `DELETE FROM account_transfers WHERE id = $1;`
result, err := r.db.Exec(query, id)
if err != nil {
return err
}
rows, err := result.RowsAffected()
if err == nil && rows == 0 {
return ErrTransferNotFound
}
return err
}
func (r *moneyAccountRepository) GetAccountBalance(accountID string) (decimal.Decimal, error) {
var balance decimal.Decimal
query := `SELECT COALESCE(SUM(CASE WHEN direction = 'deposit' THEN CAST(amount AS DECIMAL) ELSE -CAST(amount AS DECIMAL) END), 0) FROM account_transfers WHERE account_id = $1;`
err := r.db.Get(&balance, query, accountID)
return balance, err
}
func (r *moneyAccountRepository) GetTotalAllocatedForSpace(spaceID string) (decimal.Decimal, error) {
var total decimal.Decimal
query := `SELECT COALESCE(SUM(CASE WHEN t.direction = 'deposit' THEN CAST(t.amount AS DECIMAL) ELSE -CAST(t.amount AS DECIMAL) END), 0)
FROM account_transfers t
JOIN money_accounts a ON t.account_id = a.id
WHERE a.space_id = $1;`
err := r.db.Get(&total, query, spaceID)
return total, err
}
func (r *moneyAccountRepository) GetTransfersBySpaceIDPaginated(spaceID string, limit, offset int) ([]*model.AccountTransferWithAccount, error) {
var transfers []*model.AccountTransferWithAccount
query := `SELECT t.id, t.account_id, t.amount, t.direction, t.note,
t.recurring_deposit_id, t.created_by, t.created_at, a.name AS account_name
FROM account_transfers t
JOIN money_accounts a ON t.account_id = a.id
WHERE a.space_id = $1
ORDER BY t.created_at DESC
LIMIT $2 OFFSET $3;`
err := r.db.Select(&transfers, query, spaceID, limit, offset)
if err != nil {
return nil, err
}
return transfers, nil
}
func (r *moneyAccountRepository) CountTransfersBySpaceID(spaceID string) (int, error) {
var count int
query := `SELECT COUNT(*) FROM account_transfers t
JOIN money_accounts a ON t.account_id = a.id
WHERE a.space_id = $1;`
err := r.db.Get(&count, query, spaceID)
return count, err
}

View file

@ -1,170 +0,0 @@
package repository
import (
"testing"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/testutil"
"github.com/google/uuid"
"github.com/shopspring/decimal"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestMoneyAccountRepository_Create(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewMoneyAccountRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
now := time.Now()
account := &model.MoneyAccount{
ID: uuid.NewString(),
SpaceID: space.ID,
Name: "Savings",
CreatedBy: user.ID,
CreatedAt: now,
UpdatedAt: now,
}
err := repo.Create(account)
require.NoError(t, err)
fetched, err := repo.GetByID(account.ID)
require.NoError(t, err)
assert.Equal(t, account.ID, fetched.ID)
assert.Equal(t, space.ID, fetched.SpaceID)
assert.Equal(t, "Savings", fetched.Name)
assert.Equal(t, user.ID, fetched.CreatedBy)
})
}
func TestMoneyAccountRepository_GetBySpaceID(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewMoneyAccountRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
testutil.CreateTestMoneyAccount(t, dbi.DB, space.ID, "Account A", user.ID)
testutil.CreateTestMoneyAccount(t, dbi.DB, space.ID, "Account B", user.ID)
accounts, err := repo.GetBySpaceID(space.ID)
require.NoError(t, err)
assert.Len(t, accounts, 2)
})
}
func TestMoneyAccountRepository_Update(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewMoneyAccountRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
account := testutil.CreateTestMoneyAccount(t, dbi.DB, space.ID, "Original", user.ID)
account.Name = "Renamed"
err := repo.Update(account)
require.NoError(t, err)
fetched, err := repo.GetByID(account.ID)
require.NoError(t, err)
assert.Equal(t, "Renamed", fetched.Name)
})
}
func TestMoneyAccountRepository_Delete(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewMoneyAccountRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
account := testutil.CreateTestMoneyAccount(t, dbi.DB, space.ID, "To Delete", user.ID)
err := repo.Delete(account.ID)
require.NoError(t, err)
_, err = repo.GetByID(account.ID)
assert.ErrorIs(t, err, ErrMoneyAccountNotFound)
})
}
func TestMoneyAccountRepository_CreateTransfer(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewMoneyAccountRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
account := testutil.CreateTestMoneyAccount(t, dbi.DB, space.ID, "Checking", user.ID)
transfer := &model.AccountTransfer{
ID: uuid.NewString(),
AccountID: account.ID,
Amount: decimal.RequireFromString("49.95"),
Direction: model.TransferDirectionDeposit,
Note: "Initial deposit",
CreatedBy: user.ID,
CreatedAt: time.Now(),
}
err := repo.CreateTransfer(transfer)
require.NoError(t, err)
transfers, err := repo.GetTransfersByAccountID(account.ID)
require.NoError(t, err)
require.Len(t, transfers, 1)
assert.Equal(t, transfer.ID, transfers[0].ID)
assert.True(t, decimal.RequireFromString("49.95").Equal(transfers[0].Amount))
assert.Equal(t, model.TransferDirectionDeposit, transfers[0].Direction)
})
}
func TestMoneyAccountRepository_DeleteTransfer(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewMoneyAccountRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
account := testutil.CreateTestMoneyAccount(t, dbi.DB, space.ID, "Checking", user.ID)
transfer := testutil.CreateTestTransfer(t, dbi.DB, account.ID, decimal.RequireFromString("10.25"), model.TransferDirectionDeposit, user.ID)
err := repo.DeleteTransfer(transfer.ID)
require.NoError(t, err)
transfers, err := repo.GetTransfersByAccountID(account.ID)
require.NoError(t, err)
assert.Empty(t, transfers)
})
}
func TestMoneyAccountRepository_GetAccountBalance(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewMoneyAccountRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
account := testutil.CreateTestMoneyAccount(t, dbi.DB, space.ID, "Checking", user.ID)
testutil.CreateTestTransfer(t, dbi.DB, account.ID, decimal.RequireFromString("10.50"), model.TransferDirectionDeposit, user.ID)
testutil.CreateTestTransfer(t, dbi.DB, account.ID, decimal.RequireFromString("3.25"), model.TransferDirectionWithdrawal, user.ID)
balance, err := repo.GetAccountBalance(account.ID)
require.NoError(t, err)
assert.True(t, decimal.RequireFromString("7.25").Equal(balance))
})
}
func TestMoneyAccountRepository_GetTotalAllocatedForSpace(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewMoneyAccountRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
account1 := testutil.CreateTestMoneyAccount(t, dbi.DB, space.ID, "Account A", user.ID)
account2 := testutil.CreateTestMoneyAccount(t, dbi.DB, space.ID, "Account B", user.ID)
testutil.CreateTestTransfer(t, dbi.DB, account1.ID, decimal.RequireFromString("20.75"), model.TransferDirectionDeposit, user.ID)
testutil.CreateTestTransfer(t, dbi.DB, account2.ID, decimal.RequireFromString("29.50"), model.TransferDirectionDeposit, user.ID)
total, err := repo.GetTotalAllocatedForSpace(space.ID)
require.NoError(t, err)
assert.True(t, decimal.RequireFromString("50.25").Equal(total))
})
}

View file

@ -1,83 +0,0 @@
package repository
import (
"database/sql"
"errors"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"github.com/jmoiron/sqlx"
)
var (
ErrPaymentMethodNotFound = errors.New("payment method not found")
)
type PaymentMethodRepository interface {
Create(method *model.PaymentMethod) error
GetByID(id string) (*model.PaymentMethod, error)
GetBySpaceID(spaceID string) ([]*model.PaymentMethod, error)
Update(method *model.PaymentMethod) error
Delete(id string) error
}
type paymentMethodRepository struct {
db *sqlx.DB
}
func NewPaymentMethodRepository(db *sqlx.DB) PaymentMethodRepository {
return &paymentMethodRepository{db: db}
}
func (r *paymentMethodRepository) Create(method *model.PaymentMethod) error {
query := `INSERT INTO payment_methods (id, space_id, name, type, last_four, created_by, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8);`
_, err := r.db.Exec(query, method.ID, method.SpaceID, method.Name, method.Type, method.LastFour, method.CreatedBy, method.CreatedAt, method.UpdatedAt)
return err
}
func (r *paymentMethodRepository) GetByID(id string) (*model.PaymentMethod, error) {
method := &model.PaymentMethod{}
query := `SELECT * FROM payment_methods WHERE id = $1;`
err := r.db.Get(method, query, id)
if err == sql.ErrNoRows {
return nil, ErrPaymentMethodNotFound
}
return method, err
}
func (r *paymentMethodRepository) GetBySpaceID(spaceID string) ([]*model.PaymentMethod, error) {
var methods []*model.PaymentMethod
query := `SELECT * FROM payment_methods WHERE space_id = $1 ORDER BY created_at DESC;`
err := r.db.Select(&methods, query, spaceID)
if err != nil {
return nil, err
}
return methods, nil
}
func (r *paymentMethodRepository) Update(method *model.PaymentMethod) error {
method.UpdatedAt = time.Now()
query := `UPDATE payment_methods SET name = $1, type = $2, last_four = $3, updated_at = $4 WHERE id = $5;`
result, err := r.db.Exec(query, method.Name, method.Type, method.LastFour, method.UpdatedAt, method.ID)
if err != nil {
return err
}
rows, err := result.RowsAffected()
if err == nil && rows == 0 {
return ErrPaymentMethodNotFound
}
return err
}
func (r *paymentMethodRepository) Delete(id string) error {
query := `DELETE FROM payment_methods WHERE id = $1;`
result, err := r.db.Exec(query, id)
if err != nil {
return err
}
rows, err := result.RowsAffected()
if err == nil && rows == 0 {
return ErrPaymentMethodNotFound
}
return err
}

View file

@ -1,97 +0,0 @@
package repository
import (
"testing"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/testutil"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestPaymentMethodRepository_Create(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewPaymentMethodRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
lastFour := "4242"
now := time.Now()
method := &model.PaymentMethod{
ID: uuid.NewString(),
SpaceID: space.ID,
Name: "Visa Gold",
Type: model.PaymentMethodTypeCredit,
LastFour: &lastFour,
CreatedBy: user.ID,
CreatedAt: now,
UpdatedAt: now,
}
err := repo.Create(method)
require.NoError(t, err)
fetched, err := repo.GetByID(method.ID)
require.NoError(t, err)
assert.Equal(t, method.ID, fetched.ID)
assert.Equal(t, space.ID, fetched.SpaceID)
assert.Equal(t, "Visa Gold", fetched.Name)
assert.Equal(t, model.PaymentMethodTypeCredit, fetched.Type)
require.NotNil(t, fetched.LastFour)
assert.Equal(t, "4242", *fetched.LastFour)
assert.Equal(t, user.ID, fetched.CreatedBy)
})
}
func TestPaymentMethodRepository_GetBySpaceID(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewPaymentMethodRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
testutil.CreateTestPaymentMethod(t, dbi.DB, space.ID, "Visa", model.PaymentMethodTypeCredit, user.ID)
testutil.CreateTestPaymentMethod(t, dbi.DB, space.ID, "Debit Card", model.PaymentMethodTypeDebit, user.ID)
methods, err := repo.GetBySpaceID(space.ID)
require.NoError(t, err)
assert.Len(t, methods, 2)
})
}
func TestPaymentMethodRepository_Update(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewPaymentMethodRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
method := testutil.CreateTestPaymentMethod(t, dbi.DB, space.ID, "Old Card", model.PaymentMethodTypeCredit, user.ID)
method.Name = "New Card"
method.Type = model.PaymentMethodTypeDebit
err := repo.Update(method)
require.NoError(t, err)
fetched, err := repo.GetByID(method.ID)
require.NoError(t, err)
assert.Equal(t, "New Card", fetched.Name)
assert.Equal(t, model.PaymentMethodTypeDebit, fetched.Type)
})
}
func TestPaymentMethodRepository_Delete(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewPaymentMethodRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
method := testutil.CreateTestPaymentMethod(t, dbi.DB, space.ID, "To Delete", model.PaymentMethodTypeCredit, user.ID)
err := repo.Delete(method.ID)
require.NoError(t, err)
_, err = repo.GetByID(method.ID)
assert.ErrorIs(t, err, ErrPaymentMethodNotFound)
})
}

View file

@ -1,107 +0,0 @@
package repository
import (
"database/sql"
"errors"
"fmt"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"github.com/jmoiron/sqlx"
)
var (
ErrProfileNotFound = errors.New("profile not found")
)
type ProfileRepository interface {
Create(profile *model.Profile) (string, error)
ByUserID(userID string) (*model.Profile, error)
UpdateName(userID, name string) error
UpdateTimezone(userID, timezone string) error
}
type profileRepository struct {
db *sqlx.DB
}
func NewProfileRepository(db *sqlx.DB) *profileRepository {
return &profileRepository{db: db}
}
func (r *profileRepository) Create(profile *model.Profile) (string, error) {
if profile.CreatedAt.IsZero() {
profile.CreatedAt = time.Now()
}
if profile.UpdatedAt.IsZero() {
profile.UpdatedAt = time.Now()
}
_, err := r.db.Exec(`
INSERT INTO profiles (id, user_id, name, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5)
`, profile.ID, profile.UserID, profile.Name, profile.CreatedAt, profile.UpdatedAt)
if err != nil {
return "", err
}
return profile.ID, nil
}
func (r *profileRepository) ByUserID(userID string) (*model.Profile, error) {
var profile model.Profile
err := r.db.Get(&profile, `SELECT * FROM profiles WHERE user_id = $1`, userID)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrProfileNotFound
}
if err != nil {
return nil, err
}
return &profile, nil
}
func (r *profileRepository) UpdateTimezone(userID, timezone string) error {
result, err := r.db.Exec(`
UPDATE profiles
SET timezone = $1, updated_at = $2
WHERE user_id = $3
`, timezone, time.Now(), userID)
if err != nil {
return err
}
rows, err := result.RowsAffected()
if err != nil {
return err
}
if rows == 0 {
return fmt.Errorf("no profile found for user_id: %s", userID)
}
return nil
}
func (r *profileRepository) UpdateName(userID, name string) error {
result, err := r.db.Exec(`
UPDATE profiles
SET name = $1, updated_at = $2
WHERE user_id = $3
`, name, time.Now(), userID)
if err != nil {
return err
}
rows, err := result.RowsAffected()
if err != nil {
return err
}
if rows == 0 {
return fmt.Errorf("no profile found for user_id: %s", userID)
}
return nil
}

View file

@ -1,63 +0,0 @@
package repository
import (
"testing"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/testutil"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestProfileRepository_Create(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewProfileRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "profile-create@example.com", nil)
now := time.Now()
profile := &model.Profile{
ID: uuid.NewString(),
UserID: user.ID,
Name: "Test User",
CreatedAt: now,
UpdatedAt: now,
}
id, err := repo.Create(profile)
require.NoError(t, err)
assert.Equal(t, profile.ID, id)
fetched, err := repo.ByUserID(user.ID)
require.NoError(t, err)
assert.Equal(t, "Test User", fetched.Name)
assert.Equal(t, user.ID, fetched.UserID)
})
}
func TestProfileRepository_UpdateName(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewProfileRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "profile-update@example.com", nil)
testutil.CreateTestProfile(t, dbi.DB, user.ID, "Old Name")
err := repo.UpdateName(user.ID, "New Name")
require.NoError(t, err)
fetched, err := repo.ByUserID(user.ID)
require.NoError(t, err)
assert.Equal(t, "New Name", fetched.Name)
})
}
func TestProfileRepository_NotFound(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewProfileRepository(dbi.DB)
_, err := repo.ByUserID("nonexistent-id")
assert.ErrorIs(t, err, ErrProfileNotFound)
})
}

View file

@ -1,327 +0,0 @@
package repository
import (
"database/sql"
"errors"
"git.juancwu.dev/juancwu/budgit/internal/model"
"github.com/jmoiron/sqlx"
"github.com/shopspring/decimal"
)
var (
ErrReceiptNotFound = errors.New("receipt not found")
)
type ReceiptRepository interface {
CreateWithSources(
receipt *model.Receipt,
sources []model.ReceiptFundingSource,
balanceExpense *model.Expense,
accountTransfers []*model.AccountTransfer,
) error
GetByID(id string) (*model.Receipt, error)
GetByLoanIDPaginated(loanID string, limit, offset int) ([]*model.Receipt, error)
CountByLoanID(loanID string) (int, error)
GetBySpaceIDPaginated(spaceID string, limit, offset int) ([]*model.Receipt, error)
CountBySpaceID(spaceID string) (int, error)
GetFundingSourcesByReceiptID(receiptID string) ([]model.ReceiptFundingSource, error)
GetFundingSourcesWithAccountsByReceiptIDs(receiptIDs []string) (map[string][]model.ReceiptFundingSourceWithAccount, error)
DeleteWithReversal(receiptID string) error
UpdateWithSources(
receipt *model.Receipt,
sources []model.ReceiptFundingSource,
balanceExpense *model.Expense,
accountTransfers []*model.AccountTransfer,
) error
}
type receiptRepository struct {
db *sqlx.DB
}
func NewReceiptRepository(db *sqlx.DB) ReceiptRepository {
return &receiptRepository{db: db}
}
func (r *receiptRepository) CreateWithSources(
receipt *model.Receipt,
sources []model.ReceiptFundingSource,
balanceExpense *model.Expense,
accountTransfers []*model.AccountTransfer,
) error {
tx, err := r.db.Beginx()
if err != nil {
return err
}
defer tx.Rollback()
// Insert receipt
_, err = tx.Exec(
`INSERT INTO receipts (id, loan_id, space_id, description, total_amount, date, recurring_receipt_id, created_by, created_at, updated_at, total_amount_cents)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 0);`,
receipt.ID, receipt.LoanID, receipt.SpaceID, receipt.Description, receipt.TotalAmount, receipt.Date, receipt.RecurringReceiptID, receipt.CreatedBy, receipt.CreatedAt, receipt.UpdatedAt,
)
if err != nil {
return err
}
// Insert balance expense if present
if balanceExpense != nil {
_, err = tx.Exec(
`INSERT INTO expenses (id, space_id, created_by, description, amount, type, date, payment_method_id, recurring_expense_id, created_at, updated_at, amount_cents)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, 0);`,
balanceExpense.ID, balanceExpense.SpaceID, balanceExpense.CreatedBy, balanceExpense.Description, balanceExpense.Amount, balanceExpense.Type, balanceExpense.Date, balanceExpense.PaymentMethodID, balanceExpense.RecurringExpenseID, balanceExpense.CreatedAt, balanceExpense.UpdatedAt,
)
if err != nil {
return err
}
}
// Insert account transfers
for _, transfer := range accountTransfers {
_, err = tx.Exec(
`INSERT INTO account_transfers (id, account_id, amount, direction, note, recurring_deposit_id, created_by, created_at, amount_cents)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 0);`,
transfer.ID, transfer.AccountID, transfer.Amount, transfer.Direction, transfer.Note, transfer.RecurringDepositID, transfer.CreatedBy, transfer.CreatedAt,
)
if err != nil {
return err
}
}
// Insert funding sources
for _, src := range sources {
_, err = tx.Exec(
`INSERT INTO receipt_funding_sources (id, receipt_id, source_type, account_id, amount, linked_expense_id, linked_transfer_id, amount_cents)
Values ($1, $2, $3, $4, $5, $6, $7, 0);`,
src.ID, src.ReceiptID, src.SourceType, src.AccountID, src.Amount, src.LinkedExpenseID, src.LinkedTransferID,
)
if err != nil {
return err
}
}
return tx.Commit()
}
func (r *receiptRepository) GetByID(id string) (*model.Receipt, error) {
receipt := &model.Receipt{}
query := `SELECT * FROM receipts WHERE id = $1;`
err := r.db.Get(receipt, query, id)
if err == sql.ErrNoRows {
return nil, ErrReceiptNotFound
}
return receipt, err
}
func (r *receiptRepository) GetByLoanIDPaginated(loanID string, limit, offset int) ([]*model.Receipt, error) {
var receipts []*model.Receipt
query := `SELECT * FROM receipts WHERE loan_id = $1 ORDER BY date DESC, created_at DESC LIMIT $2 OFFSET $3;`
err := r.db.Select(&receipts, query, loanID, limit, offset)
return receipts, err
}
func (r *receiptRepository) CountByLoanID(loanID string) (int, error) {
var count int
err := r.db.Get(&count, `SELECT COUNT(*) FROM receipts WHERE loan_id = $1;`, loanID)
return count, err
}
func (r *receiptRepository) GetBySpaceIDPaginated(spaceID string, limit, offset int) ([]*model.Receipt, error) {
var receipts []*model.Receipt
query := `SELECT * FROM receipts WHERE space_id = $1 ORDER BY date DESC, created_at DESC LIMIT $2 OFFSET $3;`
err := r.db.Select(&receipts, query, spaceID, limit, offset)
return receipts, err
}
func (r *receiptRepository) CountBySpaceID(spaceID string) (int, error) {
var count int
err := r.db.Get(&count, `SELECT COUNT(*) FROM receipts WHERE space_id = $1;`, spaceID)
return count, err
}
func (r *receiptRepository) GetFundingSourcesByReceiptID(receiptID string) ([]model.ReceiptFundingSource, error) {
var sources []model.ReceiptFundingSource
query := `SELECT * FROM receipt_funding_sources WHERE receipt_id = $1;`
err := r.db.Select(&sources, query, receiptID)
return sources, err
}
func (r *receiptRepository) GetFundingSourcesWithAccountsByReceiptIDs(receiptIDs []string) (map[string][]model.ReceiptFundingSourceWithAccount, error) {
if len(receiptIDs) == 0 {
return make(map[string][]model.ReceiptFundingSourceWithAccount), nil
}
type row struct {
ID string `db:"id"`
ReceiptID string `db:"receipt_id"`
SourceType model.FundingSourceType `db:"source_type"`
AccountID *string `db:"account_id"`
Amount decimal.Decimal `db:"amount"`
LinkedExpenseID *string `db:"linked_expense_id"`
LinkedTransferID *string `db:"linked_transfer_id"`
AccountName *string `db:"account_name"`
}
query, args, err := sqlx.In(`
SELECT rfs.id, rfs.receipt_id, rfs.source_type, rfs.account_id, rfs.amount,
rfs.linked_expense_id, rfs.linked_transfer_id,
ma.name AS account_name
FROM receipt_funding_sources rfs
LEFT JOIN money_accounts ma ON rfs.account_id = ma.id
WHERE rfs.receipt_id IN (?)
ORDER BY rfs.source_type ASC;
`, receiptIDs)
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.ReceiptFundingSourceWithAccount)
for _, rw := range rows {
accountName := ""
if rw.AccountName != nil {
accountName = *rw.AccountName
}
result[rw.ReceiptID] = append(result[rw.ReceiptID], model.ReceiptFundingSourceWithAccount{
ReceiptFundingSource: model.ReceiptFundingSource{
ID: rw.ID,
ReceiptID: rw.ReceiptID,
SourceType: rw.SourceType,
AccountID: rw.AccountID,
Amount: rw.Amount,
LinkedExpenseID: rw.LinkedExpenseID,
LinkedTransferID: rw.LinkedTransferID,
},
AccountName: accountName,
})
}
return result, nil
}
func (r *receiptRepository) DeleteWithReversal(receiptID string) error {
tx, err := r.db.Beginx()
if err != nil {
return err
}
defer tx.Rollback()
// Get all funding sources for this receipt
var sources []model.ReceiptFundingSource
if err := tx.Select(&sources, `SELECT * FROM receipt_funding_sources WHERE receipt_id = $1;`, receiptID); err != nil {
return err
}
// Delete linked expenses and transfers
for _, src := range sources {
if src.LinkedExpenseID != nil {
if _, err := tx.Exec(`DELETE FROM expenses WHERE id = $1;`, *src.LinkedExpenseID); err != nil {
return err
}
}
if src.LinkedTransferID != nil {
if _, err := tx.Exec(`DELETE FROM account_transfers WHERE id = $1;`, *src.LinkedTransferID); err != nil {
return err
}
}
}
// Delete funding sources (cascade would handle this, but be explicit)
if _, err := tx.Exec(`DELETE FROM receipt_funding_sources WHERE receipt_id = $1;`, receiptID); err != nil {
return err
}
// Delete the receipt
if _, err := tx.Exec(`DELETE FROM receipts WHERE id = $1;`, receiptID); err != nil {
return err
}
return tx.Commit()
}
func (r *receiptRepository) UpdateWithSources(
receipt *model.Receipt,
sources []model.ReceiptFundingSource,
balanceExpense *model.Expense,
accountTransfers []*model.AccountTransfer,
) error {
tx, err := r.db.Beginx()
if err != nil {
return err
}
defer tx.Rollback()
// Delete old linked records
var oldSources []model.ReceiptFundingSource
if err := tx.Select(&oldSources, `SELECT * FROM receipt_funding_sources WHERE receipt_id = $1;`, receipt.ID); err != nil {
return err
}
for _, src := range oldSources {
if src.LinkedExpenseID != nil {
if _, err := tx.Exec(`DELETE FROM expenses WHERE id = $1;`, *src.LinkedExpenseID); err != nil {
return err
}
}
if src.LinkedTransferID != nil {
if _, err := tx.Exec(`DELETE FROM account_transfers WHERE id = $1;`, *src.LinkedTransferID); err != nil {
return err
}
}
}
if _, err := tx.Exec(`DELETE FROM receipt_funding_sources WHERE receipt_id = $1;`, receipt.ID); err != nil {
return err
}
// Update receipt
_, err = tx.Exec(
`UPDATE receipts SET description = $1, total_amount = $2, date = $3, updated_at = $4 WHERE id = $5;`,
receipt.Description, receipt.TotalAmount, receipt.Date, receipt.UpdatedAt, receipt.ID,
)
if err != nil {
return err
}
// Insert new balance expense
if balanceExpense != nil {
_, err = tx.Exec(
`INSERT INTO expenses (id, space_id, created_by, description, amount, type, date, payment_method_id, recurring_expense_id, created_at, updated_at, amount_cents)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, 0);`,
balanceExpense.ID, balanceExpense.SpaceID, balanceExpense.CreatedBy, balanceExpense.Description, balanceExpense.Amount, balanceExpense.Type, balanceExpense.Date, balanceExpense.PaymentMethodID, balanceExpense.RecurringExpenseID, balanceExpense.CreatedAt, balanceExpense.UpdatedAt,
)
if err != nil {
return err
}
}
// Insert new account transfers
for _, transfer := range accountTransfers {
_, err = tx.Exec(
`INSERT INTO account_transfers (id, account_id, amount, direction, note, recurring_deposit_id, created_by, created_at, amount_cents)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 0);`,
transfer.ID, transfer.AccountID, transfer.Amount, transfer.Direction, transfer.Note, transfer.RecurringDepositID, transfer.CreatedBy, transfer.CreatedAt,
)
if err != nil {
return err
}
}
// Insert new funding sources
for _, src := range sources {
_, err = tx.Exec(
`INSERT INTO receipt_funding_sources (id, receipt_id, source_type, account_id, amount, linked_expense_id, linked_transfer_id, amount_cents)
Values ($1, $2, $3, $4, $5, $6, $7, 0);`,
src.ID, src.ReceiptID, src.SourceType, src.AccountID, src.Amount, src.LinkedExpenseID, src.LinkedTransferID,
)
if err != nil {
return err
}
}
return tx.Commit()
}

View file

@ -1,224 +0,0 @@
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 {
return WithTx(r.db, func(tx *sqlx.Tx) error {
query := `INSERT INTO recurring_expenses (id, space_id, created_by, description, amount, type, payment_method_id, frequency, start_date, end_date, next_occurrence, is_active, created_at, updated_at, amount_cents)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, 0);`
if _, err := tx.Exec(query, re.ID, re.SpaceID, re.CreatedBy, re.Description, re.Amount, re.Type, re.PaymentMethodID, re.Frequency, re.StartDate, re.EndDate, re.NextOccurrence, re.IsActive, re.CreatedAt, re.UpdatedAt); 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 nil
})
}
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 {
return WithTx(r.db, func(tx *sqlx.Tx) error {
query := `UPDATE recurring_expenses SET description = $1, amount = $2, type = $3, payment_method_id = $4, frequency = $5, start_date = $6, end_date = $7, next_occurrence = $8, updated_at = $9 WHERE id = $10;`
if _, err := tx.Exec(query, re.Description, re.Amount, re.Type, re.PaymentMethodID, re.Frequency, re.StartDate, re.EndDate, re.NextOccurrence, re.UpdatedAt, re.ID); err != nil {
return err
}
if _, err := tx.Exec(`DELETE FROM recurring_expense_tags WHERE recurring_expense_id = $1;`, re.ID); 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 nil
})
}
func (r *recurringExpenseRepository) Delete(id string) error {
result, err := r.db.Exec(`DELETE FROM recurring_expenses WHERE id = $1;`, id)
if err != nil {
return err
}
rows, err := result.RowsAffected()
if err == nil && rows == 0 {
return ErrRecurringExpenseNotFound
}
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)
}

View file

@ -1,165 +0,0 @@
package repository
import (
"database/sql"
"errors"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"github.com/jmoiron/sqlx"
)
var (
ErrRecurringReceiptNotFound = errors.New("recurring receipt not found")
)
type RecurringReceiptRepository interface {
Create(rr *model.RecurringReceipt, sources []model.RecurringReceiptSource) error
GetByID(id string) (*model.RecurringReceipt, error)
GetByLoanID(loanID string) ([]*model.RecurringReceipt, error)
GetBySpaceID(spaceID string) ([]*model.RecurringReceipt, error)
GetSourcesByRecurringReceiptID(id string) ([]model.RecurringReceiptSource, error)
Update(rr *model.RecurringReceipt, sources []model.RecurringReceiptSource) error
Delete(id string) error
SetActive(id string, active bool) error
Deactivate(id string) error
GetDueRecurrences(now time.Time) ([]*model.RecurringReceipt, error)
GetDueRecurrencesForSpace(spaceID string, now time.Time) ([]*model.RecurringReceipt, error)
UpdateNextOccurrence(id string, next time.Time) error
}
type recurringReceiptRepository struct {
db *sqlx.DB
}
func NewRecurringReceiptRepository(db *sqlx.DB) RecurringReceiptRepository {
return &recurringReceiptRepository{db: db}
}
func (r *recurringReceiptRepository) Create(rr *model.RecurringReceipt, sources []model.RecurringReceiptSource) error {
tx, err := r.db.Beginx()
if err != nil {
return err
}
defer tx.Rollback()
_, err = tx.Exec(
`INSERT INTO recurring_receipts (id, loan_id, space_id, description, total_amount, frequency, start_date, end_date, next_occurrence, is_active, created_by, created_at, updated_at, total_amount_cents)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, 0);`,
rr.ID, rr.LoanID, rr.SpaceID, rr.Description, rr.TotalAmount, rr.Frequency, rr.StartDate, rr.EndDate, rr.NextOccurrence, rr.IsActive, rr.CreatedBy, rr.CreatedAt, rr.UpdatedAt,
)
if err != nil {
return err
}
for _, src := range sources {
_, err = tx.Exec(
`INSERT INTO recurring_receipt_sources (id, recurring_receipt_id, source_type, account_id, amount, amount_cents)
VALUES ($1, $2, $3, $4, $5, 0);`,
src.ID, src.RecurringReceiptID, src.SourceType, src.AccountID, src.Amount,
)
if err != nil {
return err
}
}
return tx.Commit()
}
func (r *recurringReceiptRepository) GetByID(id string) (*model.RecurringReceipt, error) {
rr := &model.RecurringReceipt{}
query := `SELECT * FROM recurring_receipts WHERE id = $1;`
err := r.db.Get(rr, query, id)
if err == sql.ErrNoRows {
return nil, ErrRecurringReceiptNotFound
}
return rr, err
}
func (r *recurringReceiptRepository) GetByLoanID(loanID string) ([]*model.RecurringReceipt, error) {
var results []*model.RecurringReceipt
query := `SELECT * FROM recurring_receipts WHERE loan_id = $1 ORDER BY is_active DESC, next_occurrence ASC;`
err := r.db.Select(&results, query, loanID)
return results, err
}
func (r *recurringReceiptRepository) GetBySpaceID(spaceID string) ([]*model.RecurringReceipt, error) {
var results []*model.RecurringReceipt
query := `SELECT * FROM recurring_receipts WHERE space_id = $1 ORDER BY is_active DESC, next_occurrence ASC;`
err := r.db.Select(&results, query, spaceID)
return results, err
}
func (r *recurringReceiptRepository) GetSourcesByRecurringReceiptID(id string) ([]model.RecurringReceiptSource, error) {
var sources []model.RecurringReceiptSource
query := `SELECT * FROM recurring_receipt_sources WHERE recurring_receipt_id = $1;`
err := r.db.Select(&sources, query, id)
return sources, err
}
func (r *recurringReceiptRepository) Update(rr *model.RecurringReceipt, sources []model.RecurringReceiptSource) error {
tx, err := r.db.Beginx()
if err != nil {
return err
}
defer tx.Rollback()
_, err = tx.Exec(
`UPDATE recurring_receipts SET description = $1, total_amount = $2, frequency = $3, start_date = $4, end_date = $5, next_occurrence = $6, updated_at = $7 WHERE id = $8;`,
rr.Description, rr.TotalAmount, rr.Frequency, rr.StartDate, rr.EndDate, rr.NextOccurrence, rr.UpdatedAt, rr.ID,
)
if err != nil {
return err
}
// Replace sources
if _, err := tx.Exec(`DELETE FROM recurring_receipt_sources WHERE recurring_receipt_id = $1;`, rr.ID); err != nil {
return err
}
for _, src := range sources {
_, err = tx.Exec(
`INSERT INTO recurring_receipt_sources (id, recurring_receipt_id, source_type, account_id, amount, amount_cents)
VALUES ($1, $2, $3, $4, $5, 0);`,
src.ID, src.RecurringReceiptID, src.SourceType, src.AccountID, src.Amount,
)
if err != nil {
return err
}
}
return tx.Commit()
}
func (r *recurringReceiptRepository) Delete(id string) error {
_, err := r.db.Exec(`DELETE FROM recurring_receipts WHERE id = $1;`, id)
return err
}
func (r *recurringReceiptRepository) SetActive(id string, active bool) error {
_, err := r.db.Exec(`UPDATE recurring_receipts SET is_active = $1, updated_at = $2 WHERE id = $3;`, active, time.Now(), id)
return err
}
func (r *recurringReceiptRepository) Deactivate(id string) error {
return r.SetActive(id, false)
}
func (r *recurringReceiptRepository) GetDueRecurrences(now time.Time) ([]*model.RecurringReceipt, error) {
var results []*model.RecurringReceipt
query := `SELECT * FROM recurring_receipts WHERE is_active = true AND next_occurrence <= $1;`
err := r.db.Select(&results, query, now)
return results, err
}
func (r *recurringReceiptRepository) GetDueRecurrencesForSpace(spaceID string, now time.Time) ([]*model.RecurringReceipt, error) {
var results []*model.RecurringReceipt
query := `SELECT * FROM recurring_receipts 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 *recurringReceiptRepository) UpdateNextOccurrence(id string, next time.Time) error {
_, err := r.db.Exec(`UPDATE recurring_receipts SET next_occurrence = $1, updated_at = $2 WHERE id = $3;`, next, time.Now(), id)
return err
}

View file

@ -1,83 +0,0 @@
package repository
import (
"database/sql"
"errors"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"github.com/jmoiron/sqlx"
)
var (
ErrShoppingListNotFound = errors.New("shopping list not found")
)
type ShoppingListRepository interface {
Create(list *model.ShoppingList) error
GetByID(id string) (*model.ShoppingList, error)
GetBySpaceID(spaceID string) ([]*model.ShoppingList, error)
Update(list *model.ShoppingList) error
Delete(id string) error
}
type shoppingListRepository struct {
db *sqlx.DB
}
func NewShoppingListRepository(db *sqlx.DB) ShoppingListRepository {
return &shoppingListRepository{db: db}
}
func (r *shoppingListRepository) Create(list *model.ShoppingList) error {
query := `INSERT INTO shopping_lists (id, space_id, name, created_at, updated_at) VALUES ($1, $2, $3, $4, $5);`
_, err := r.db.Exec(query, list.ID, list.SpaceID, list.Name, list.CreatedAt, list.UpdatedAt)
return err
}
func (r *shoppingListRepository) GetByID(id string) (*model.ShoppingList, error) {
list := &model.ShoppingList{}
query := `SELECT * FROM shopping_lists WHERE id = $1;`
err := r.db.Get(list, query, id)
if err == sql.ErrNoRows {
return nil, ErrShoppingListNotFound
}
return list, err
}
func (r *shoppingListRepository) GetBySpaceID(spaceID string) ([]*model.ShoppingList, error) {
var lists []*model.ShoppingList
query := `SELECT * FROM shopping_lists WHERE space_id = $1 ORDER BY created_at DESC;`
err := r.db.Select(&lists, query, spaceID)
if err != nil {
return nil, err
}
return lists, nil
}
func (r *shoppingListRepository) Update(list *model.ShoppingList) error {
list.UpdatedAt = time.Now()
query := `UPDATE shopping_lists SET name = $1, updated_at = $2 WHERE id = $3;`
result, err := r.db.Exec(query, list.Name, list.UpdatedAt, list.ID)
if err != nil {
return err
}
rows, err := result.RowsAffected()
if err == nil && rows == 0 {
return ErrShoppingListNotFound
}
return err
}
func (r *shoppingListRepository) Delete(id string) error {
query := `DELETE FROM shopping_lists WHERE id = $1;`
result, err := r.db.Exec(query, id)
if err != nil {
return err
}
rows, err := result.RowsAffected()
if err == nil && rows == 0 {
return ErrShoppingListNotFound
}
return err
}

View file

@ -1,93 +0,0 @@
package repository
import (
"testing"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/testutil"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestShoppingListRepository_Create(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewShoppingListRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
now := time.Now()
list := &model.ShoppingList{
ID: uuid.NewString(),
SpaceID: space.ID,
Name: "Groceries",
CreatedAt: now,
UpdatedAt: now,
}
err := repo.Create(list)
require.NoError(t, err)
fetched, err := repo.GetByID(list.ID)
require.NoError(t, err)
assert.Equal(t, list.ID, fetched.ID)
assert.Equal(t, space.ID, fetched.SpaceID)
assert.Equal(t, "Groceries", fetched.Name)
})
}
func TestShoppingListRepository_GetBySpaceID(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewShoppingListRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
list1 := testutil.CreateTestShoppingList(t, dbi.DB, space.ID, "List A")
// Small delay to ensure distinct created_at timestamps for ordering.
time.Sleep(10 * time.Millisecond)
list2 := testutil.CreateTestShoppingList(t, dbi.DB, space.ID, "List B")
lists, err := repo.GetBySpaceID(space.ID)
require.NoError(t, err)
require.Len(t, lists, 2)
// Ordered by created_at DESC, so list2 should be first.
assert.Equal(t, list2.ID, lists[0].ID)
assert.Equal(t, list1.ID, lists[1].ID)
})
}
func TestShoppingListRepository_Update(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewShoppingListRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
list := testutil.CreateTestShoppingList(t, dbi.DB, space.ID, "Original Name")
list.Name = "Updated Name"
err := repo.Update(list)
require.NoError(t, err)
fetched, err := repo.GetByID(list.ID)
require.NoError(t, err)
assert.Equal(t, "Updated Name", fetched.Name)
})
}
func TestShoppingListRepository_Delete(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewShoppingListRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
list := testutil.CreateTestShoppingList(t, dbi.DB, space.ID, "To Delete")
err := repo.Delete(list.ID)
require.NoError(t, err)
_, err = repo.GetByID(list.ID)
assert.ErrorIs(t, err, ErrShoppingListNotFound)
})
}

View file

@ -22,7 +22,7 @@ type SpaceRepository interface {
IsMember(spaceID, userID string) (bool, error)
GetMembers(spaceID string) ([]*model.SpaceMemberWithProfile, error)
UpdateName(spaceID, name string) error
UpdateTimezone(spaceID, timezone string) error
Delete(spaceID string) error
}
@ -115,10 +115,9 @@ func (r *spaceRepository) GetMembers(spaceID string) ([]*model.SpaceMemberWithPr
var members []*model.SpaceMemberWithProfile
query := `
SELECT sm.space_id, sm.user_id, sm.role, sm.joined_at,
p.name, u.email
u.name, u.email
FROM space_members sm
JOIN users u ON sm.user_id = u.id
JOIN profiles p ON sm.user_id = p.user_id
WHERE sm.space_id = $1
ORDER BY sm.role DESC, sm.joined_at ASC;`
err := r.db.Select(&members, query, spaceID)
@ -131,11 +130,6 @@ func (r *spaceRepository) UpdateName(spaceID, name string) error {
return err
}
func (r *spaceRepository) UpdateTimezone(spaceID, timezone string) error {
query := `UPDATE spaces SET timezone = $1, updated_at = $2 WHERE id = $3;`
_, err := r.db.Exec(query, timezone, time.Now(), spaceID)
return err
}
func (r *spaceRepository) Delete(spaceID string) error {
query := `DELETE FROM spaces WHERE id = $1;`

View file

@ -95,8 +95,10 @@ func TestSpaceRepository_GetMembers(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewSpaceRepository(dbi.DB)
owner, _ := testutil.CreateTestUserWithProfile(t, dbi.DB, "members-owner@example.com", "Owner")
member, _ := testutil.CreateTestUserWithProfile(t, dbi.DB, "members-member@example.com", "Member")
ownerName := "Owner"
memberName := "Member"
owner := testutil.CreateTestUserWithName(t, dbi.DB, "members-owner@example.com", &ownerName)
member := testutil.CreateTestUserWithName(t, dbi.DB, "members-member@example.com", &memberName)
space := testutil.CreateTestSpace(t, dbi.DB, owner.ID, "Members Space")
err := repo.AddMember(space.ID, member.ID, model.RoleMember)
@ -108,9 +110,11 @@ func TestSpaceRepository_GetMembers(t *testing.T) {
// The query orders by role DESC (owner first), then joined_at ASC.
assert.Equal(t, model.RoleOwner, members[0].Role)
assert.Equal(t, "Owner", members[0].Name)
require.NotNil(t, members[0].Name)
assert.Equal(t, "Owner", *members[0].Name)
assert.Equal(t, model.RoleMember, members[1].Role)
assert.Equal(t, "Member", members[1].Name)
require.NotNil(t, members[1].Name)
assert.Equal(t, "Member", *members[1].Name)
})
}

View file

@ -1,96 +0,0 @@
package repository
import (
"database/sql"
"errors"
"strings"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"github.com/jmoiron/sqlx"
)
var (
ErrTagNotFound = errors.New("tag not found")
ErrDuplicateTagName = errors.New("tag with that name already exists in this space")
)
type TagRepository interface {
Create(tag *model.Tag) error
GetByID(id string) (*model.Tag, error)
GetBySpaceID(spaceID string) ([]*model.Tag, error)
Update(tag *model.Tag) error
Delete(id string) error
}
type tagRepository struct {
db *sqlx.DB
}
func NewTagRepository(db *sqlx.DB) TagRepository {
return &tagRepository{db: db}
}
func (r *tagRepository) Create(tag *model.Tag) error {
query := `INSERT INTO tags (id, space_id, name, color, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6);`
_, err := r.db.Exec(query, tag.ID, tag.SpaceID, tag.Name, tag.Color, tag.CreatedAt, tag.UpdatedAt)
if err != nil {
errStr := err.Error()
if strings.Contains(errStr, "UNIQUE constraint failed") || strings.Contains(errStr, "duplicate key value") {
return ErrDuplicateTagName
}
return err
}
return nil
}
func (r *tagRepository) GetByID(id string) (*model.Tag, error) {
tag := &model.Tag{}
query := `SELECT * FROM tags WHERE id = $1;`
err := r.db.Get(tag, query, id)
if err == sql.ErrNoRows {
return nil, ErrTagNotFound
}
return tag, err
}
func (r *tagRepository) GetBySpaceID(spaceID string) ([]*model.Tag, error) {
var tags []*model.Tag
query := `SELECT * FROM tags WHERE space_id = $1 ORDER BY name ASC;`
err := r.db.Select(&tags, query, spaceID)
if err != nil {
return nil, err
}
return tags, nil
}
func (r *tagRepository) Update(tag *model.Tag) error {
tag.UpdatedAt = time.Now()
query := `UPDATE tags SET name = $1, color = $2, updated_at = $3 WHERE id = $4;`
result, err := r.db.Exec(query, tag.Name, tag.Color, tag.UpdatedAt, tag.ID)
if err != nil {
errStr := err.Error()
if strings.Contains(errStr, "UNIQUE constraint failed") || strings.Contains(errStr, "duplicate key value") {
return ErrDuplicateTagName
}
return err
}
rows, err := result.RowsAffected()
if err == nil && rows == 0 {
return ErrTagNotFound
}
return err
}
func (r *tagRepository) Delete(id string) error {
query := `DELETE FROM tags WHERE id = $1;`
result, err := r.db.Exec(query, id)
if err != nil {
return err
}
rows, err := result.RowsAffected()
if err == nil && rows == 0 {
return ErrTagNotFound
}
return err
}

View file

@ -1,120 +0,0 @@
package repository
import (
"testing"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/testutil"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestTagRepository_Create(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewTagRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "tag-create@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Tag Space")
color := "#ff0000"
now := time.Now()
tag := &model.Tag{
ID: uuid.NewString(),
SpaceID: space.ID,
Name: "Groceries",
Color: &color,
CreatedAt: now,
UpdatedAt: now,
}
err := repo.Create(tag)
require.NoError(t, err)
fetched, err := repo.GetByID(tag.ID)
require.NoError(t, err)
assert.Equal(t, "Groceries", fetched.Name)
assert.Equal(t, &color, fetched.Color)
assert.Equal(t, space.ID, fetched.SpaceID)
})
}
func TestTagRepository_GetBySpaceID(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewTagRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "tag-list@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Tag List Space")
// Create tags with names that sort alphabetically: "Alpha" < "Beta".
testutil.CreateTestTag(t, dbi.DB, space.ID, "Beta", nil)
testutil.CreateTestTag(t, dbi.DB, space.ID, "Alpha", nil)
tags, err := repo.GetBySpaceID(space.ID)
require.NoError(t, err)
require.Len(t, tags, 2)
assert.Equal(t, "Alpha", tags[0].Name)
assert.Equal(t, "Beta", tags[1].Name)
})
}
func TestTagRepository_Update(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewTagRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "tag-update@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Tag Update Space")
tag := testutil.CreateTestTag(t, dbi.DB, space.ID, "Old Tag", nil)
newColor := "#00ff00"
tag.Name = "New Tag"
tag.Color = &newColor
err := repo.Update(tag)
require.NoError(t, err)
fetched, err := repo.GetByID(tag.ID)
require.NoError(t, err)
assert.Equal(t, "New Tag", fetched.Name)
assert.Equal(t, &newColor, fetched.Color)
})
}
func TestTagRepository_Delete(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewTagRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "tag-delete@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Tag Delete Space")
tag := testutil.CreateTestTag(t, dbi.DB, space.ID, "Doomed Tag", nil)
err := repo.Delete(tag.ID)
require.NoError(t, err)
_, err = repo.GetByID(tag.ID)
assert.ErrorIs(t, err, ErrTagNotFound)
})
}
func TestTagRepository_DuplicateTagName(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewTagRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "tag-dup@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Tag Dup Space")
testutil.CreateTestTag(t, dbi.DB, space.ID, "Duplicate", nil)
now := time.Now()
duplicate := &model.Tag{
ID: uuid.NewString(),
SpaceID: space.ID,
Name: "Duplicate",
CreatedAt: now,
UpdatedAt: now,
}
err := repo.Create(duplicate)
assert.ErrorIs(t, err, ErrDuplicateTagName)
})
}

View file

@ -31,9 +31,9 @@ func NewUserRepository(db *sqlx.DB) UserRepository {
}
func (r *userRepository) Create(user *model.User) (string, error) {
query := `INSERT INTO users (id, email, password_hash, email_verified_at, created_at) VALUES ($1, $2, $3, $4, $5);`
query := `INSERT INTO users (id, email, name, password_hash, email_verified_at, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7);`
_, err := r.db.Exec(query, user.ID, user.Email, user.PasswordHash, user.EmailVerifiedAt, user.CreatedAt)
_, err := r.db.Exec(query, user.ID, user.Email, user.Name, user.PasswordHash, user.EmailVerifiedAt, user.CreatedAt, user.UpdatedAt)
if err != nil {
errStr := err.Error()
if strings.Contains(errStr, "UNIQUE constraint failed") || strings.Contains(errStr, "duplicate key value") {
@ -70,9 +70,9 @@ func (r *userRepository) ByEmail(email string) (*model.User, error) {
}
func (r *userRepository) Update(user *model.User) error {
query := `UPDATE users SET email = $1, password_hash = $2, pending_email = $3, email_verified_at = $4 WHERE id = $5;`
query := `UPDATE users SET email = $1, name = $2, password_hash = $3, pending_email = $4, email_verified_at = $5, updated_at = $6 WHERE id = $7;`
_, err := r.db.Exec(query, user.Email, user.PasswordHash, user.PendingEmail, user.EmailVerifiedAt, user.ID)
_, err := r.db.Exec(query, user.Email, user.Name, user.PasswordHash, user.PendingEmail, user.EmailVerifiedAt, user.UpdatedAt, user.ID)
return err
}