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

@ -34,7 +34,6 @@ var (
type AuthService struct {
emailService *EmailService
userRepository repository.UserRepository
profileRepository repository.ProfileRepository
tokenRepository repository.TokenRepository
spaceService *SpaceService
jwtSecret string
@ -46,7 +45,6 @@ type AuthService struct {
func NewAuthService(
emailService *EmailService,
userRepository repository.UserRepository,
profileRepository repository.ProfileRepository,
tokenRepository repository.TokenRepository,
spaceService *SpaceService,
jwtSecret string,
@ -55,15 +53,14 @@ func NewAuthService(
isProduction bool,
) *AuthService {
return &AuthService{
emailService: emailService,
userRepository: userRepository,
profileRepository: profileRepository,
tokenRepository: tokenRepository,
spaceService: spaceService,
jwtSecret: jwtSecret,
jwtExpiry: jwtExpiry,
emailService: emailService,
userRepository: userRepository,
tokenRepository: tokenRepository,
spaceService: spaceService,
jwtSecret: jwtSecret,
jwtExpiry: jwtExpiry,
tokenMagicLinkExpiry: tokenMagicLinkExpiry,
isProduction: isProduction,
isProduction: isProduction,
}
}
@ -233,34 +230,20 @@ func (s *AuthService) SendMagicLink(email string) error {
user, err := s.userRepository.ByEmail(email)
if err != nil {
// User doesn't exists - create a new passwordless account
// User doesn't exist - create a new passwordless account
if errors.Is(err, repository.ErrUserNotFound) {
now := time.Now()
user = &model.User{
ID: uuid.NewString(),
Email: email,
CreatedAt: now,
UpdatedAt: now,
}
_, err := s.userRepository.Create(user)
if err != nil {
return fmt.Errorf("failed to create user: %w", err)
}
slog.Info("new user created with id", "id", user.ID)
profile := &model.Profile{
ID: uuid.NewString(),
UserID: user.ID,
Name: "",
CreatedAt: now,
UpdatedAt: now,
}
_, err = s.profileRepository.Create(profile)
if err != nil {
return fmt.Errorf("failed to create profile: %w", err)
}
slog.Info("new passwordless user created", "email", email, "user_id", user.ID)
} else {
// user look up unexpected error
@ -291,10 +274,9 @@ func (s *AuthService) SendMagicLink(email string) error {
return fmt.Errorf("failed to create token: %w", err)
}
profile, err := s.profileRepository.ByUserID(user.ID)
name := ""
if err == nil && profile != nil {
name = profile.Name
if user.Name != nil {
name = *user.Name
}
err = s.emailService.SendMagicLinkEmail(user.Email, magicToken, name)
@ -341,12 +323,12 @@ func (s *AuthService) VerifyMagicLink(tokenString string) (*model.User, error) {
// NeedsOnboarding checks if user needs to complete onboarding (name not set)
func (s *AuthService) NeedsOnboarding(userID string) (bool, error) {
profile, err := s.profileRepository.ByUserID(userID)
user, err := s.userRepository.ByID(userID)
if err != nil {
return false, fmt.Errorf("failed to get profile: %w", err)
return false, fmt.Errorf("failed to get user: %w", err)
}
return profile.Name == "", nil
return user.Name == nil || *user.Name == "", nil
}
// CompleteOnboarding sets the user's name during onboarding
@ -358,17 +340,20 @@ func (s *AuthService) CompleteOnboarding(userID, name string) error {
return err
}
err = s.profileRepository.UpdateName(userID, name)
user, err := s.userRepository.ByID(userID)
if err != nil {
return fmt.Errorf("failed to update profile: %w", err)
return fmt.Errorf("failed to get user: %w", err)
}
user, err := s.userRepository.ByID(userID)
if err == nil {
err = s.emailService.SendWelcomeEmail(user.Email, name)
if err != nil {
slog.Warn("failed to send welcome email", "error", err, "email", user.Email)
}
user.Name = &name
err = s.userRepository.Update(user)
if err != nil {
return fmt.Errorf("failed to update user: %w", err)
}
err = s.emailService.SendWelcomeEmail(user.Email, name)
if err != nil {
slog.Warn("failed to send welcome email", "error", err, "email", user.Email)
}
slog.Info("onboarding completed", "user_id", user.ID, "name", name)

View file

@ -14,7 +14,6 @@ import (
func newTestAuthService(dbi testutil.DBInfo) *AuthService {
cfg := testutil.TestConfig()
userRepo := repository.NewUserRepository(dbi.DB)
profileRepo := repository.NewProfileRepository(dbi.DB)
tokenRepo := repository.NewTokenRepository(dbi.DB)
spaceRepo := repository.NewSpaceRepository(dbi.DB)
spaceSvc := NewSpaceService(spaceRepo)
@ -22,7 +21,6 @@ func newTestAuthService(dbi testutil.DBInfo) *AuthService {
return NewAuthService(
emailSvc,
userRepo,
profileRepo,
tokenRepo,
spaceSvc,
cfg.JWTSecret,
@ -45,12 +43,6 @@ func TestAuthService_SendMagicLink(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, "newuser@example.com", user.Email)
// Verify profile was created in DB
profileRepo := repository.NewProfileRepository(dbi.DB)
profile, err := profileRepo.ByUserID(user.ID)
require.NoError(t, err)
assert.Equal(t, "", profile.Name)
// Verify token was created in DB
var tokenCount int
err = dbi.DB.Get(&tokenCount, `SELECT COUNT(*) FROM tokens WHERE user_id = $1 AND type = $2`, user.ID, model.TokenTypeMagicLink)
@ -161,17 +153,18 @@ func TestAuthService_NeedsOnboarding(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
svc := newTestAuthService(dbi)
// User with empty name needs onboarding
userEmpty, _ := testutil.CreateTestUserWithProfile(t, dbi.DB, "empty@example.com", "")
// User with no name needs onboarding
userEmpty := testutil.CreateTestUser(t, dbi.DB, "empty@example.com", nil)
needs, err := svc.NeedsOnboarding(userEmpty.ID)
require.NoError(t, err)
assert.True(t, needs)
// User with a name does not need onboarding
userNamed, _ := testutil.CreateTestUserWithProfile(t, dbi.DB, "named@example.com", "Jane Doe")
err = svc.CompleteOnboarding(userEmpty.ID, "Jane Doe")
require.NoError(t, err)
needs, err = svc.NeedsOnboarding(userNamed.ID)
needs, err = svc.NeedsOnboarding(userEmpty.ID)
require.NoError(t, err)
assert.False(t, needs)
})
@ -181,15 +174,16 @@ func TestAuthService_CompleteOnboarding(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
svc := newTestAuthService(dbi)
user, _ := testutil.CreateTestUserWithProfile(t, dbi.DB, "onboard@example.com", "")
user := testutil.CreateTestUser(t, dbi.DB, "onboard@example.com", nil)
err := svc.CompleteOnboarding(user.ID, "New Name")
require.NoError(t, err)
// Verify profile name was updated
profileRepo := repository.NewProfileRepository(dbi.DB)
profile, err := profileRepo.ByUserID(user.ID)
// Verify user name was updated
userRepo := repository.NewUserRepository(dbi.DB)
updated, err := userRepo.ByID(user.ID)
require.NoError(t, err)
assert.Equal(t, "New Name", profile.Name)
assert.NotNil(t, updated.Name)
assert.Equal(t, "New Name", *updated.Name)
})
}

View file

@ -1,186 +0,0 @@
package service
import (
"fmt"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/repository"
"github.com/google/uuid"
"github.com/shopspring/decimal"
)
type CreateBudgetDTO struct {
SpaceID string
TagIDs []string
Amount decimal.Decimal
Period model.BudgetPeriod
StartDate time.Time
EndDate *time.Time
CreatedBy string
}
type UpdateBudgetDTO struct {
ID string
TagIDs []string
Amount decimal.Decimal
Period model.BudgetPeriod
StartDate time.Time
EndDate *time.Time
}
type BudgetService struct {
budgetRepo repository.BudgetRepository
}
func NewBudgetService(budgetRepo repository.BudgetRepository) *BudgetService {
return &BudgetService{budgetRepo: budgetRepo}
}
func (s *BudgetService) CreateBudget(dto CreateBudgetDTO) (*model.Budget, error) {
if dto.Amount.LessThanOrEqual(decimal.Zero) {
return nil, fmt.Errorf("budget amount must be positive")
}
if len(dto.TagIDs) == 0 {
return nil, fmt.Errorf("at least one tag is required")
}
now := time.Now()
budget := &model.Budget{
ID: uuid.NewString(),
SpaceID: dto.SpaceID,
Amount: dto.Amount,
Period: dto.Period,
StartDate: dto.StartDate,
EndDate: dto.EndDate,
IsActive: true,
CreatedBy: dto.CreatedBy,
CreatedAt: now,
UpdatedAt: now,
}
if err := s.budgetRepo.Create(budget, dto.TagIDs); err != nil {
return nil, err
}
return budget, nil
}
func (s *BudgetService) GetBudget(id string) (*model.Budget, error) {
return s.budgetRepo.GetByID(id)
}
func (s *BudgetService) GetBudgetsWithSpent(spaceID string) ([]*model.BudgetWithSpent, error) {
budgets, err := s.budgetRepo.GetBySpaceID(spaceID)
if err != nil {
return nil, err
}
// Collect budget IDs for batch tag fetch
budgetIDs := make([]string, len(budgets))
for i, b := range budgets {
budgetIDs[i] = b.ID
}
budgetTagsMap, err := s.budgetRepo.GetTagsByBudgetIDs(budgetIDs)
if err != nil {
return nil, err
}
result := make([]*model.BudgetWithSpent, 0, len(budgets))
for _, b := range budgets {
tags := budgetTagsMap[b.ID]
// Extract tag IDs for spending calculation
tagIDs := make([]string, len(tags))
for i, t := range tags {
tagIDs[i] = t.ID
}
start, end := GetCurrentPeriodBounds(b.Period, time.Now())
spent, err := s.budgetRepo.GetSpentForBudget(spaceID, tagIDs, start, end)
if err != nil {
spent = decimal.Zero
}
var percentage float64
if b.Amount.GreaterThan(decimal.Zero) {
percentage, _ = spent.Div(b.Amount).Mul(decimal.NewFromInt(100)).Float64()
}
var status model.BudgetStatus
switch {
case percentage > 100:
status = model.BudgetStatusOver
case percentage >= 75:
status = model.BudgetStatusWarning
default:
status = model.BudgetStatusOnTrack
}
bws := &model.BudgetWithSpent{
Budget: *b,
Tags: tags,
Spent: spent,
Percentage: percentage,
Status: status,
}
result = append(result, bws)
}
return result, nil
}
func (s *BudgetService) UpdateBudget(dto UpdateBudgetDTO) (*model.Budget, error) {
if dto.Amount.LessThanOrEqual(decimal.Zero) {
return nil, fmt.Errorf("budget amount must be positive")
}
if len(dto.TagIDs) == 0 {
return nil, fmt.Errorf("at least one tag is required")
}
existing, err := s.budgetRepo.GetByID(dto.ID)
if err != nil {
return nil, err
}
existing.Amount = dto.Amount
existing.Period = dto.Period
existing.StartDate = dto.StartDate
existing.EndDate = dto.EndDate
existing.UpdatedAt = time.Now()
if err := s.budgetRepo.Update(existing, dto.TagIDs); err != nil {
return nil, err
}
return existing, nil
}
func (s *BudgetService) DeleteBudget(id string) error {
return s.budgetRepo.Delete(id)
}
func GetCurrentPeriodBounds(period model.BudgetPeriod, now time.Time) (time.Time, time.Time) {
switch period {
case model.BudgetPeriodWeekly:
weekday := int(now.Weekday())
if weekday == 0 {
weekday = 7
}
start := now.AddDate(0, 0, -(weekday - 1))
start = time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, now.Location())
end := start.AddDate(0, 0, 6)
end = time.Date(end.Year(), end.Month(), end.Day(), 23, 59, 59, 0, now.Location())
return start, end
case model.BudgetPeriodYearly:
start := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, now.Location())
end := time.Date(now.Year(), 12, 31, 23, 59, 59, 0, now.Location())
return start, end
default: // monthly
start := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
end := start.AddDate(0, 1, -1)
end = time.Date(end.Year(), end.Month(), end.Day(), 23, 59, 59, 0, now.Location())
return start, end
}
}

View file

@ -1,245 +0,0 @@
package service
import (
"fmt"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/repository"
"github.com/google/uuid"
"github.com/shopspring/decimal"
)
type CreateExpenseDTO struct {
SpaceID string
UserID string
Description string
Amount decimal.Decimal
Type model.ExpenseType
Date time.Time
TagIDs []string
ItemIDs []string
PaymentMethodID *string
}
type UpdateExpenseDTO struct {
ID string
SpaceID string
Description string
Amount decimal.Decimal
Type model.ExpenseType
Date time.Time
TagIDs []string
PaymentMethodID *string
}
const ExpensesPerPage = 25
type ExpenseService struct {
expenseRepo repository.ExpenseRepository
}
func NewExpenseService(expenseRepo repository.ExpenseRepository) *ExpenseService {
return &ExpenseService{
expenseRepo: expenseRepo,
}
}
func (s *ExpenseService) CreateExpense(dto CreateExpenseDTO) (*model.Expense, error) {
if dto.Description == "" {
return nil, fmt.Errorf("expense description cannot be empty")
}
if dto.Amount.LessThanOrEqual(decimal.Zero) {
return nil, fmt.Errorf("amount must be positive")
}
now := time.Now()
expense := &model.Expense{
ID: uuid.NewString(),
SpaceID: dto.SpaceID,
CreatedBy: dto.UserID,
Description: dto.Description,
Amount: dto.Amount,
Type: dto.Type,
Date: dto.Date,
PaymentMethodID: dto.PaymentMethodID,
CreatedAt: now,
UpdatedAt: now,
}
err := s.expenseRepo.Create(expense, dto.TagIDs, dto.ItemIDs)
if err != nil {
return nil, err
}
return expense, nil
}
func (s *ExpenseService) GetExpensesForSpace(spaceID string) ([]*model.Expense, error) {
return s.expenseRepo.GetBySpaceID(spaceID)
}
func (s *ExpenseService) GetBalanceForSpace(spaceID string) (decimal.Decimal, error) {
expenses, err := s.expenseRepo.GetBySpaceID(spaceID)
if err != nil {
return decimal.Zero, err
}
balance := decimal.Zero
for _, expense := range expenses {
if expense.Type == model.ExpenseTypeExpense {
balance = balance.Sub(expense.Amount)
} else if expense.Type == model.ExpenseTypeTopup {
balance = balance.Add(expense.Amount)
}
}
return balance, nil
}
func (s *ExpenseService) GetExpensesByTag(spaceID string, fromDate, toDate time.Time) ([]*model.TagExpenseSummary, error) {
return s.expenseRepo.GetExpensesByTag(spaceID, fromDate, toDate)
}
func (s *ExpenseService) GetExpensesWithTagsForSpace(spaceID string) ([]*model.ExpenseWithTags, error) {
expenses, err := s.expenseRepo.GetBySpaceID(spaceID)
if err != nil {
return nil, err
}
ids := make([]string, len(expenses))
for i, e := range expenses {
ids[i] = e.ID
}
tagsMap, err := s.expenseRepo.GetTagsByExpenseIDs(ids)
if err != nil {
return nil, err
}
result := make([]*model.ExpenseWithTags, len(expenses))
for i, e := range expenses {
result[i] = &model.ExpenseWithTags{
Expense: *e,
Tags: tagsMap[e.ID],
}
}
return result, nil
}
func (s *ExpenseService) GetExpensesWithTagsForSpacePaginated(spaceID string, page int) ([]*model.ExpenseWithTags, int, error) {
total, err := s.expenseRepo.CountBySpaceID(spaceID)
if err != nil {
return nil, 0, err
}
page, totalPages, offset := Paginate(page, total, ExpensesPerPage)
expenses, err := s.expenseRepo.GetBySpaceIDPaginated(spaceID, ExpensesPerPage, offset)
if err != nil {
return nil, 0, err
}
ids := make([]string, len(expenses))
for i, e := range expenses {
ids[i] = e.ID
}
tagsMap, err := s.expenseRepo.GetTagsByExpenseIDs(ids)
if err != nil {
return nil, 0, err
}
result := make([]*model.ExpenseWithTags, len(expenses))
for i, e := range expenses {
result[i] = &model.ExpenseWithTags{
Expense: *e,
Tags: tagsMap[e.ID],
}
}
return result, totalPages, nil
}
func (s *ExpenseService) GetExpensesWithTagsAndMethodsForSpacePaginated(spaceID string, page int) ([]*model.ExpenseWithTagsAndMethod, int, error) {
total, err := s.expenseRepo.CountBySpaceID(spaceID)
if err != nil {
return nil, 0, err
}
page, totalPages, offset := Paginate(page, total, ExpensesPerPage)
expenses, err := s.expenseRepo.GetBySpaceIDPaginated(spaceID, ExpensesPerPage, offset)
if err != nil {
return nil, 0, err
}
ids := make([]string, len(expenses))
for i, e := range expenses {
ids[i] = e.ID
}
tagsMap, err := s.expenseRepo.GetTagsByExpenseIDs(ids)
if err != nil {
return nil, 0, err
}
methodsMap, err := s.expenseRepo.GetPaymentMethodsByExpenseIDs(ids)
if err != nil {
return nil, 0, err
}
result := make([]*model.ExpenseWithTagsAndMethod, len(expenses))
for i, e := range expenses {
result[i] = &model.ExpenseWithTagsAndMethod{
Expense: *e,
Tags: tagsMap[e.ID],
PaymentMethod: methodsMap[e.ID],
}
}
return result, totalPages, nil
}
func (s *ExpenseService) GetPaymentMethodsByExpenseIDs(expenseIDs []string) (map[string]*model.PaymentMethod, error) {
return s.expenseRepo.GetPaymentMethodsByExpenseIDs(expenseIDs)
}
func (s *ExpenseService) GetExpense(id string) (*model.Expense, error) {
return s.expenseRepo.GetByID(id)
}
func (s *ExpenseService) GetTagsByExpenseIDs(expenseIDs []string) (map[string][]*model.Tag, error) {
return s.expenseRepo.GetTagsByExpenseIDs(expenseIDs)
}
func (s *ExpenseService) UpdateExpense(dto UpdateExpenseDTO) (*model.Expense, error) {
if dto.Description == "" {
return nil, fmt.Errorf("expense description cannot be empty")
}
if dto.Amount.LessThanOrEqual(decimal.Zero) {
return nil, fmt.Errorf("amount must be positive")
}
existing, err := s.expenseRepo.GetByID(dto.ID)
if err != nil {
return nil, err
}
existing.Description = dto.Description
existing.Amount = dto.Amount
existing.Type = dto.Type
existing.Date = dto.Date
existing.PaymentMethodID = dto.PaymentMethodID
existing.UpdatedAt = time.Now()
if err := s.expenseRepo.Update(existing, dto.TagIDs); err != nil {
return nil, err
}
return existing, nil
}
func (s *ExpenseService) DeleteExpense(id string, spaceID string) error {
if err := s.expenseRepo.Delete(id); err != nil {
return err
}
return nil
}

View file

@ -1,233 +0,0 @@
package service
import (
"testing"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/repository"
"git.juancwu.dev/juancwu/budgit/internal/testutil"
"github.com/shopspring/decimal"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestExpenseService_CreateExpense(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
expenseRepo := repository.NewExpenseRepository(dbi.DB)
svc := NewExpenseService(expenseRepo)
user := testutil.CreateTestUser(t, dbi.DB, "exp-svc-create@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Expense Svc Space")
tag := testutil.CreateTestTag(t, dbi.DB, space.ID, "Food", nil)
expense, err := svc.CreateExpense(CreateExpenseDTO{
SpaceID: space.ID,
UserID: user.ID,
Description: "Lunch",
Amount: decimal.RequireFromString("15.49"),
Type: model.ExpenseTypeExpense,
Date: time.Now(),
TagIDs: []string{tag.ID},
})
require.NoError(t, err)
assert.NotEmpty(t, expense.ID)
assert.Equal(t, "Lunch", expense.Description)
assert.True(t, decimal.RequireFromString("15.49").Equal(expense.Amount))
assert.Equal(t, model.ExpenseTypeExpense, expense.Type)
})
}
func TestExpenseService_CreateExpense_EmptyDescription(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
expenseRepo := repository.NewExpenseRepository(dbi.DB)
svc := NewExpenseService(expenseRepo)
expense, err := svc.CreateExpense(CreateExpenseDTO{
SpaceID: "some-space",
UserID: "some-user",
Description: "",
Amount: decimal.RequireFromString("10.75"),
Type: model.ExpenseTypeExpense,
Date: time.Now(),
})
assert.Error(t, err)
assert.Nil(t, expense)
})
}
func TestExpenseService_CreateExpense_ZeroAmount(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
expenseRepo := repository.NewExpenseRepository(dbi.DB)
svc := NewExpenseService(expenseRepo)
expense, err := svc.CreateExpense(CreateExpenseDTO{
SpaceID: "some-space",
UserID: "some-user",
Description: "Something",
Amount: decimal.Zero,
Type: model.ExpenseTypeExpense,
Date: time.Now(),
})
assert.Error(t, err)
assert.Nil(t, expense)
})
}
func TestExpenseService_GetExpensesWithTagsForSpacePaginated(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
expenseRepo := repository.NewExpenseRepository(dbi.DB)
svc := NewExpenseService(expenseRepo)
user := testutil.CreateTestUser(t, dbi.DB, "exp-svc-paginate@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Expense Svc Paginate Space")
tag := testutil.CreateTestTag(t, dbi.DB, space.ID, "Transport", nil)
// Create expense with tag via the service
_, err := svc.CreateExpense(CreateExpenseDTO{
SpaceID: space.ID,
UserID: user.ID,
Description: "Bus fare",
Amount: decimal.RequireFromString("2.49"),
Type: model.ExpenseTypeExpense,
Date: time.Now(),
TagIDs: []string{tag.ID},
})
require.NoError(t, err)
// Create expense without tag
_, err = svc.CreateExpense(CreateExpenseDTO{
SpaceID: space.ID,
UserID: user.ID,
Description: "Coffee",
Amount: decimal.RequireFromString("5.01"),
Type: model.ExpenseTypeExpense,
Date: time.Now(),
})
require.NoError(t, err)
results, totalPages, err := svc.GetExpensesWithTagsForSpacePaginated(space.ID, 1)
require.NoError(t, err)
assert.Len(t, results, 2)
assert.Equal(t, 1, totalPages)
// Verify at least one result has tags and one does not
var withTags, withoutTags int
for _, r := range results {
if len(r.Tags) > 0 {
withTags++
} else {
withoutTags++
}
}
assert.Equal(t, 1, withTags)
assert.Equal(t, 1, withoutTags)
})
}
func TestExpenseService_GetBalanceForSpace(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
expenseRepo := repository.NewExpenseRepository(dbi.DB)
svc := NewExpenseService(expenseRepo)
user := testutil.CreateTestUser(t, dbi.DB, "exp-svc-balance@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Expense Svc Balance Space")
testutil.CreateTestExpense(t, dbi.DB, space.ID, user.ID, "Topup", decimal.RequireFromString("100.50"), model.ExpenseTypeTopup)
testutil.CreateTestExpense(t, dbi.DB, space.ID, user.ID, "Groceries", decimal.RequireFromString("30.75"), model.ExpenseTypeExpense)
balance, err := svc.GetBalanceForSpace(space.ID)
require.NoError(t, err)
assert.True(t, decimal.RequireFromString("69.75").Equal(balance))
})
}
func TestExpenseService_GetExpensesByTag(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
expenseRepo := repository.NewExpenseRepository(dbi.DB)
svc := NewExpenseService(expenseRepo)
user := testutil.CreateTestUser(t, dbi.DB, "exp-svc-bytag@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Expense Svc ByTag Space")
tagColor := "#ff0000"
tag := testutil.CreateTestTag(t, dbi.DB, space.ID, "Dining", &tagColor)
now := time.Now()
_, err := svc.CreateExpense(CreateExpenseDTO{
SpaceID: space.ID,
UserID: user.ID,
Description: "Dinner",
Amount: decimal.RequireFromString("24.99"),
Type: model.ExpenseTypeExpense,
Date: now,
TagIDs: []string{tag.ID},
})
require.NoError(t, err)
fromDate := now.Add(-24 * time.Hour)
toDate := now.Add(24 * time.Hour)
summaries, err := svc.GetExpensesByTag(space.ID, fromDate, toDate)
require.NoError(t, err)
require.Len(t, summaries, 1)
assert.Equal(t, tag.ID, summaries[0].TagID)
assert.True(t, decimal.RequireFromString("24.99").Equal(summaries[0].TotalAmount))
})
}
func TestExpenseService_UpdateExpense(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
expenseRepo := repository.NewExpenseRepository(dbi.DB)
svc := NewExpenseService(expenseRepo)
user := testutil.CreateTestUser(t, dbi.DB, "exp-svc-update@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Expense Svc Update Space")
created, err := svc.CreateExpense(CreateExpenseDTO{
SpaceID: space.ID,
UserID: user.ID,
Description: "Old Description",
Amount: decimal.RequireFromString("10.75"),
Type: model.ExpenseTypeExpense,
Date: time.Now(),
})
require.NoError(t, err)
updated, err := svc.UpdateExpense(UpdateExpenseDTO{
ID: created.ID,
SpaceID: space.ID,
Description: "New Description",
Amount: decimal.RequireFromString("19.49"),
Type: model.ExpenseTypeExpense,
Date: time.Now(),
})
require.NoError(t, err)
assert.Equal(t, "New Description", updated.Description)
assert.True(t, decimal.RequireFromString("19.49").Equal(updated.Amount))
})
}
func TestExpenseService_DeleteExpense(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
expenseRepo := repository.NewExpenseRepository(dbi.DB)
svc := NewExpenseService(expenseRepo)
user := testutil.CreateTestUser(t, dbi.DB, "exp-svc-delete@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Expense Svc Delete Space")
created, err := svc.CreateExpense(CreateExpenseDTO{
SpaceID: space.ID,
UserID: user.ID,
Description: "Doomed Expense",
Amount: decimal.RequireFromString("4.99"),
Type: model.ExpenseTypeExpense,
Date: time.Now(),
})
require.NoError(t, err)
err = svc.DeleteExpense(created.ID, space.ID)
require.NoError(t, err)
_, err = svc.GetExpense(created.ID)
assert.Error(t, err)
})
}

View file

@ -1,196 +0,0 @@
package service
import (
"fmt"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/repository"
"github.com/google/uuid"
"github.com/shopspring/decimal"
)
type CreateLoanDTO struct {
SpaceID string
UserID string
Name string
Description string
OriginalAmount decimal.Decimal
InterestRateBps int
StartDate time.Time
EndDate *time.Time
}
type UpdateLoanDTO struct {
ID string
Name string
Description string
OriginalAmount decimal.Decimal
InterestRateBps int
StartDate time.Time
EndDate *time.Time
}
const LoansPerPage = 25
type LoanService struct {
loanRepo repository.LoanRepository
receiptRepo repository.ReceiptRepository
}
func NewLoanService(loanRepo repository.LoanRepository, receiptRepo repository.ReceiptRepository) *LoanService {
return &LoanService{
loanRepo: loanRepo,
receiptRepo: receiptRepo,
}
}
func (s *LoanService) CreateLoan(dto CreateLoanDTO) (*model.Loan, error) {
if dto.Name == "" {
return nil, fmt.Errorf("loan name cannot be empty")
}
if dto.OriginalAmount.LessThanOrEqual(decimal.Zero) {
return nil, fmt.Errorf("amount must be positive")
}
now := time.Now()
loan := &model.Loan{
ID: uuid.NewString(),
SpaceID: dto.SpaceID,
Name: dto.Name,
Description: dto.Description,
OriginalAmount: dto.OriginalAmount,
InterestRateBps: dto.InterestRateBps,
StartDate: dto.StartDate,
EndDate: dto.EndDate,
IsPaidOff: false,
CreatedBy: dto.UserID,
CreatedAt: now,
UpdatedAt: now,
}
if err := s.loanRepo.Create(loan); err != nil {
return nil, err
}
return loan, nil
}
func (s *LoanService) GetLoan(id string) (*model.Loan, error) {
return s.loanRepo.GetByID(id)
}
func (s *LoanService) GetLoanWithSummary(id string) (*model.LoanWithPaymentSummary, error) {
loan, err := s.loanRepo.GetByID(id)
if err != nil {
return nil, err
}
totalPaid, err := s.loanRepo.GetTotalPaidForLoan(id)
if err != nil {
return nil, err
}
receiptCount, err := s.loanRepo.GetReceiptCountForLoan(id)
if err != nil {
return nil, err
}
return &model.LoanWithPaymentSummary{
Loan: *loan,
TotalPaid: totalPaid,
Remaining: loan.OriginalAmount.Sub(totalPaid),
ReceiptCount: receiptCount,
}, nil
}
func (s *LoanService) GetLoansWithSummaryForSpace(spaceID string) ([]*model.LoanWithPaymentSummary, error) {
loans, err := s.loanRepo.GetBySpaceID(spaceID)
if err != nil {
return nil, err
}
return s.attachSummaries(loans)
}
func (s *LoanService) GetLoansWithSummaryForSpacePaginated(spaceID string, page int) ([]*model.LoanWithPaymentSummary, int, error) {
total, err := s.loanRepo.CountBySpaceID(spaceID)
if err != nil {
return nil, 0, err
}
totalPages := (total + LoansPerPage - 1) / LoansPerPage
if totalPages < 1 {
totalPages = 1
}
if page < 1 {
page = 1
}
if page > totalPages {
page = totalPages
}
offset := (page - 1) * LoansPerPage
loans, err := s.loanRepo.GetBySpaceIDPaginated(spaceID, LoansPerPage, offset)
if err != nil {
return nil, 0, err
}
result, err := s.attachSummaries(loans)
if err != nil {
return nil, 0, err
}
return result, totalPages, nil
}
func (s *LoanService) attachSummaries(loans []*model.Loan) ([]*model.LoanWithPaymentSummary, error) {
result := make([]*model.LoanWithPaymentSummary, len(loans))
for i, loan := range loans {
totalPaid, err := s.loanRepo.GetTotalPaidForLoan(loan.ID)
if err != nil {
return nil, err
}
receiptCount, err := s.loanRepo.GetReceiptCountForLoan(loan.ID)
if err != nil {
return nil, err
}
result[i] = &model.LoanWithPaymentSummary{
Loan: *loan,
TotalPaid: totalPaid,
Remaining: loan.OriginalAmount.Sub(totalPaid),
ReceiptCount: receiptCount,
}
}
return result, nil
}
func (s *LoanService) UpdateLoan(dto UpdateLoanDTO) (*model.Loan, error) {
if dto.Name == "" {
return nil, fmt.Errorf("loan name cannot be empty")
}
if dto.OriginalAmount.LessThanOrEqual(decimal.Zero) {
return nil, fmt.Errorf("amount must be positive")
}
existing, err := s.loanRepo.GetByID(dto.ID)
if err != nil {
return nil, err
}
existing.Name = dto.Name
existing.Description = dto.Description
existing.OriginalAmount = dto.OriginalAmount
existing.InterestRateBps = dto.InterestRateBps
existing.StartDate = dto.StartDate
existing.EndDate = dto.EndDate
existing.UpdatedAt = time.Now()
if err := s.loanRepo.Update(existing); err != nil {
return nil, err
}
return existing, nil
}
func (s *LoanService) DeleteLoan(id string) error {
return s.loanRepo.Delete(id)
}

View file

@ -1,191 +0,0 @@
package service
import (
"fmt"
"strings"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/repository"
"github.com/google/uuid"
"github.com/shopspring/decimal"
)
type CreateMoneyAccountDTO struct {
SpaceID string
Name string
CreatedBy string
}
type UpdateMoneyAccountDTO struct {
ID string
Name string
}
type CreateTransferDTO struct {
AccountID string
Amount decimal.Decimal
Direction model.TransferDirection
Note string
CreatedBy string
}
type MoneyAccountService struct {
accountRepo repository.MoneyAccountRepository
}
func NewMoneyAccountService(accountRepo repository.MoneyAccountRepository) *MoneyAccountService {
return &MoneyAccountService{
accountRepo: accountRepo,
}
}
func (s *MoneyAccountService) CreateAccount(dto CreateMoneyAccountDTO) (*model.MoneyAccount, error) {
name := strings.TrimSpace(dto.Name)
if name == "" {
return nil, fmt.Errorf("account name cannot be empty")
}
now := time.Now()
account := &model.MoneyAccount{
ID: uuid.NewString(),
SpaceID: dto.SpaceID,
Name: name,
CreatedBy: dto.CreatedBy,
CreatedAt: now,
UpdatedAt: now,
}
err := s.accountRepo.Create(account)
if err != nil {
return nil, err
}
return account, nil
}
func (s *MoneyAccountService) GetAccountsForSpace(spaceID string) ([]model.MoneyAccountWithBalance, error) {
accounts, err := s.accountRepo.GetBySpaceID(spaceID)
if err != nil {
return nil, err
}
result := make([]model.MoneyAccountWithBalance, len(accounts))
for i, acct := range accounts {
balance, err := s.accountRepo.GetAccountBalance(acct.ID)
if err != nil {
return nil, err
}
result[i] = model.MoneyAccountWithBalance{
MoneyAccount: *acct,
Balance: balance,
}
}
return result, nil
}
func (s *MoneyAccountService) GetAccount(id string) (*model.MoneyAccount, error) {
return s.accountRepo.GetByID(id)
}
func (s *MoneyAccountService) UpdateAccount(dto UpdateMoneyAccountDTO) (*model.MoneyAccount, error) {
name := strings.TrimSpace(dto.Name)
if name == "" {
return nil, fmt.Errorf("account name cannot be empty")
}
account, err := s.accountRepo.GetByID(dto.ID)
if err != nil {
return nil, err
}
account.Name = name
err = s.accountRepo.Update(account)
if err != nil {
return nil, err
}
return account, nil
}
func (s *MoneyAccountService) DeleteAccount(id string) error {
return s.accountRepo.Delete(id)
}
func (s *MoneyAccountService) CreateTransfer(dto CreateTransferDTO, availableSpaceBalance decimal.Decimal) (*model.AccountTransfer, error) {
if dto.Amount.LessThanOrEqual(decimal.Zero) {
return nil, fmt.Errorf("amount must be positive")
}
if dto.Direction != model.TransferDirectionDeposit && dto.Direction != model.TransferDirectionWithdrawal {
return nil, fmt.Errorf("invalid transfer direction")
}
if dto.Direction == model.TransferDirectionDeposit {
if dto.Amount.GreaterThan(availableSpaceBalance) {
return nil, fmt.Errorf("insufficient available balance")
}
}
if dto.Direction == model.TransferDirectionWithdrawal {
accountBalance, err := s.accountRepo.GetAccountBalance(dto.AccountID)
if err != nil {
return nil, err
}
if dto.Amount.GreaterThan(accountBalance) {
return nil, fmt.Errorf("insufficient account balance")
}
}
transfer := &model.AccountTransfer{
ID: uuid.NewString(),
AccountID: dto.AccountID,
Amount: dto.Amount,
Direction: dto.Direction,
Note: strings.TrimSpace(dto.Note),
CreatedBy: dto.CreatedBy,
CreatedAt: time.Now(),
}
err := s.accountRepo.CreateTransfer(transfer)
if err != nil {
return nil, err
}
return transfer, nil
}
func (s *MoneyAccountService) GetTransfersForAccount(accountID string) ([]*model.AccountTransfer, error) {
return s.accountRepo.GetTransfersByAccountID(accountID)
}
func (s *MoneyAccountService) DeleteTransfer(id string) error {
return s.accountRepo.DeleteTransfer(id)
}
func (s *MoneyAccountService) GetAccountBalance(accountID string) (decimal.Decimal, error) {
return s.accountRepo.GetAccountBalance(accountID)
}
func (s *MoneyAccountService) GetTotalAllocatedForSpace(spaceID string) (decimal.Decimal, error) {
return s.accountRepo.GetTotalAllocatedForSpace(spaceID)
}
const TransfersPerPage = 25
func (s *MoneyAccountService) GetTransfersForSpacePaginated(spaceID string, page int) ([]*model.AccountTransferWithAccount, int, error) {
total, err := s.accountRepo.CountTransfersBySpaceID(spaceID)
if err != nil {
return nil, 0, err
}
page, totalPages, offset := Paginate(page, total, TransfersPerPage)
transfers, err := s.accountRepo.GetTransfersBySpaceIDPaginated(spaceID, TransfersPerPage, offset)
if err != nil {
return nil, 0, err
}
return transfers, totalPages, nil
}

View file

@ -1,190 +0,0 @@
package service
import (
"testing"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/repository"
"git.juancwu.dev/juancwu/budgit/internal/testutil"
"github.com/shopspring/decimal"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestMoneyAccountService_CreateAccount(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
accountRepo := repository.NewMoneyAccountRepository(dbi.DB)
svc := NewMoneyAccountService(accountRepo)
user := testutil.CreateTestUser(t, dbi.DB, "acct-svc-create@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Account Svc Space")
account, err := svc.CreateAccount(CreateMoneyAccountDTO{
SpaceID: space.ID,
Name: "Savings",
CreatedBy: user.ID,
})
require.NoError(t, err)
assert.NotEmpty(t, account.ID)
assert.Equal(t, "Savings", account.Name)
assert.Equal(t, space.ID, account.SpaceID)
})
}
func TestMoneyAccountService_CreateAccount_EmptyName(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
accountRepo := repository.NewMoneyAccountRepository(dbi.DB)
svc := NewMoneyAccountService(accountRepo)
account, err := svc.CreateAccount(CreateMoneyAccountDTO{
SpaceID: "some-space",
Name: "",
CreatedBy: "some-user",
})
assert.Error(t, err)
assert.Nil(t, account)
})
}
func TestMoneyAccountService_GetAccountsForSpace(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
accountRepo := repository.NewMoneyAccountRepository(dbi.DB)
svc := NewMoneyAccountService(accountRepo)
user := testutil.CreateTestUser(t, dbi.DB, "acct-svc-list@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Account Svc List Space")
account := testutil.CreateTestMoneyAccount(t, dbi.DB, space.ID, "Checking", user.ID)
testutil.CreateTestTransfer(t, dbi.DB, account.ID, decimal.RequireFromString("49.95"), model.TransferDirectionDeposit, user.ID)
accounts, err := svc.GetAccountsForSpace(space.ID)
require.NoError(t, err)
require.Len(t, accounts, 1)
assert.Equal(t, "Checking", accounts[0].Name)
assert.True(t, decimal.RequireFromString("49.95").Equal(accounts[0].Balance))
})
}
func TestMoneyAccountService_CreateTransfer_Deposit(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
accountRepo := repository.NewMoneyAccountRepository(dbi.DB)
svc := NewMoneyAccountService(accountRepo)
user := testutil.CreateTestUser(t, dbi.DB, "acct-svc-deposit@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Account Svc Deposit Space")
account := testutil.CreateTestMoneyAccount(t, dbi.DB, space.ID, "Deposit Account", user.ID)
transfer, err := svc.CreateTransfer(CreateTransferDTO{
AccountID: account.ID,
Amount: decimal.RequireFromString("29.75"),
Direction: model.TransferDirectionDeposit,
Note: "Initial deposit",
CreatedBy: user.ID,
}, decimal.RequireFromString("100.50"))
require.NoError(t, err)
assert.NotEmpty(t, transfer.ID)
assert.True(t, decimal.RequireFromString("29.75").Equal(transfer.Amount))
assert.Equal(t, model.TransferDirectionDeposit, transfer.Direction)
})
}
func TestMoneyAccountService_CreateTransfer_InsufficientBalance(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
accountRepo := repository.NewMoneyAccountRepository(dbi.DB)
svc := NewMoneyAccountService(accountRepo)
user := testutil.CreateTestUser(t, dbi.DB, "acct-svc-insuf@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Account Svc Insuf Space")
account := testutil.CreateTestMoneyAccount(t, dbi.DB, space.ID, "Insuf Account", user.ID)
transfer, err := svc.CreateTransfer(CreateTransferDTO{
AccountID: account.ID,
Amount: decimal.RequireFromString("50.25"),
Direction: model.TransferDirectionDeposit,
Note: "Too much",
CreatedBy: user.ID,
}, decimal.RequireFromString("10.50"))
assert.Error(t, err)
assert.Nil(t, transfer)
})
}
func TestMoneyAccountService_CreateTransfer_Withdrawal(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
accountRepo := repository.NewMoneyAccountRepository(dbi.DB)
svc := NewMoneyAccountService(accountRepo)
user := testutil.CreateTestUser(t, dbi.DB, "acct-svc-withdraw@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Account Svc Withdraw Space")
account := testutil.CreateTestMoneyAccount(t, dbi.DB, space.ID, "Withdraw Account", user.ID)
testutil.CreateTestTransfer(t, dbi.DB, account.ID, decimal.RequireFromString("49.75"), model.TransferDirectionDeposit, user.ID)
transfer, err := svc.CreateTransfer(CreateTransferDTO{
AccountID: account.ID,
Amount: decimal.RequireFromString("19.50"),
Direction: model.TransferDirectionWithdrawal,
Note: "Withdrawal",
CreatedBy: user.ID,
}, decimal.Zero)
require.NoError(t, err)
assert.NotEmpty(t, transfer.ID)
assert.True(t, decimal.RequireFromString("19.50").Equal(transfer.Amount))
assert.Equal(t, model.TransferDirectionWithdrawal, transfer.Direction)
})
}
func TestMoneyAccountService_GetTotalAllocatedForSpace(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
accountRepo := repository.NewMoneyAccountRepository(dbi.DB)
svc := NewMoneyAccountService(accountRepo)
user := testutil.CreateTestUser(t, dbi.DB, "acct-svc-total@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Account Svc Total Space")
account1 := testutil.CreateTestMoneyAccount(t, dbi.DB, space.ID, "Account 1", user.ID)
testutil.CreateTestTransfer(t, dbi.DB, account1.ID, decimal.RequireFromString("30.25"), model.TransferDirectionDeposit, user.ID)
account2 := testutil.CreateTestMoneyAccount(t, dbi.DB, space.ID, "Account 2", user.ID)
testutil.CreateTestTransfer(t, dbi.DB, account2.ID, decimal.RequireFromString("19.50"), model.TransferDirectionDeposit, user.ID)
total, err := svc.GetTotalAllocatedForSpace(space.ID)
require.NoError(t, err)
assert.True(t, decimal.RequireFromString("49.75").Equal(total))
})
}
func TestMoneyAccountService_DeleteAccount(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
accountRepo := repository.NewMoneyAccountRepository(dbi.DB)
svc := NewMoneyAccountService(accountRepo)
user := testutil.CreateTestUser(t, dbi.DB, "acct-svc-del@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Account Svc Del Space")
account := testutil.CreateTestMoneyAccount(t, dbi.DB, space.ID, "Doomed Account", user.ID)
err := svc.DeleteAccount(account.ID)
require.NoError(t, err)
accounts, err := svc.GetAccountsForSpace(space.ID)
require.NoError(t, err)
assert.Empty(t, accounts)
})
}
func TestMoneyAccountService_DeleteTransfer(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
accountRepo := repository.NewMoneyAccountRepository(dbi.DB)
svc := NewMoneyAccountService(accountRepo)
user := testutil.CreateTestUser(t, dbi.DB, "acct-svc-deltx@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Account Svc DelTx Space")
account := testutil.CreateTestMoneyAccount(t, dbi.DB, space.ID, "DelTx Account", user.ID)
transfer := testutil.CreateTestTransfer(t, dbi.DB, account.ID, decimal.RequireFromString("10.25"), model.TransferDirectionDeposit, user.ID)
err := svc.DeleteTransfer(transfer.ID)
require.NoError(t, err)
transfers, err := svc.GetTransfersForAccount(account.ID)
require.NoError(t, err)
assert.Empty(t, transfers)
})
}

View file

@ -1,18 +0,0 @@
package service
// Paginate calculates pagination values from a page number, total count, and page size.
// Returns the adjusted page, total pages, and offset for the query.
func Paginate(page, total, perPage int) (adjustedPage, totalPages, offset int) {
totalPages = (total + perPage - 1) / perPage
if totalPages < 1 {
totalPages = 1
}
if page < 1 {
page = 1
}
if page > totalPages {
page = totalPages
}
offset = (page - 1) * perPage
return page, totalPages, offset
}

View file

@ -1,109 +0,0 @@
package service
import (
"fmt"
"strings"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/repository"
"github.com/google/uuid"
)
type CreatePaymentMethodDTO struct {
SpaceID string
Name string
Type model.PaymentMethodType
LastFour string
CreatedBy string
}
type UpdatePaymentMethodDTO struct {
ID string
Name string
Type model.PaymentMethodType
LastFour string
}
type PaymentMethodService struct {
methodRepo repository.PaymentMethodRepository
}
func NewPaymentMethodService(methodRepo repository.PaymentMethodRepository) *PaymentMethodService {
return &PaymentMethodService{
methodRepo: methodRepo,
}
}
func (s *PaymentMethodService) CreateMethod(dto CreatePaymentMethodDTO) (*model.PaymentMethod, error) {
name := strings.TrimSpace(dto.Name)
if name == "" {
return nil, fmt.Errorf("payment method name cannot be empty")
}
if dto.Type != model.PaymentMethodTypeCredit && dto.Type != model.PaymentMethodTypeDebit {
return nil, fmt.Errorf("invalid payment method type")
}
if len(dto.LastFour) != 4 {
return nil, fmt.Errorf("last four digits must be exactly 4 characters")
}
now := time.Now()
method := &model.PaymentMethod{
ID: uuid.NewString(),
SpaceID: dto.SpaceID,
Name: name,
Type: dto.Type,
LastFour: &dto.LastFour,
CreatedBy: dto.CreatedBy,
CreatedAt: now,
UpdatedAt: now,
}
err := s.methodRepo.Create(method)
if err != nil {
return nil, err
}
return method, nil
}
func (s *PaymentMethodService) GetMethodsForSpace(spaceID string) ([]*model.PaymentMethod, error) {
return s.methodRepo.GetBySpaceID(spaceID)
}
func (s *PaymentMethodService) GetMethod(id string) (*model.PaymentMethod, error) {
return s.methodRepo.GetByID(id)
}
func (s *PaymentMethodService) UpdateMethod(dto UpdatePaymentMethodDTO) (*model.PaymentMethod, error) {
name := strings.TrimSpace(dto.Name)
if name == "" {
return nil, fmt.Errorf("payment method name cannot be empty")
}
if dto.Type != model.PaymentMethodTypeCredit && dto.Type != model.PaymentMethodTypeDebit {
return nil, fmt.Errorf("invalid payment method type")
}
if len(dto.LastFour) != 4 {
return nil, fmt.Errorf("last four digits must be exactly 4 characters")
}
method, err := s.methodRepo.GetByID(dto.ID)
if err != nil {
return nil, err
}
method.Name = name
method.Type = dto.Type
method.LastFour = &dto.LastFour
err = s.methodRepo.Update(method)
if err != nil {
return nil, err
}
return method, nil
}
func (s *PaymentMethodService) DeleteMethod(id string) error {
return s.methodRepo.Delete(id)
}

View file

@ -1,144 +0,0 @@
package service
import (
"testing"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/repository"
"git.juancwu.dev/juancwu/budgit/internal/testutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestPaymentMethodService_CreateMethod(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
methodRepo := repository.NewPaymentMethodRepository(dbi.DB)
svc := NewPaymentMethodService(methodRepo)
user := testutil.CreateTestUser(t, dbi.DB, "pm-svc-create@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "PM Svc Space")
method, err := svc.CreateMethod(CreatePaymentMethodDTO{
SpaceID: space.ID,
Name: "Visa Card",
Type: model.PaymentMethodTypeCredit,
LastFour: "4242",
CreatedBy: user.ID,
})
require.NoError(t, err)
assert.NotEmpty(t, method.ID)
assert.Equal(t, "Visa Card", method.Name)
assert.Equal(t, model.PaymentMethodTypeCredit, method.Type)
require.NotNil(t, method.LastFour)
assert.Equal(t, "4242", *method.LastFour)
})
}
func TestPaymentMethodService_CreateMethod_EmptyName(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
methodRepo := repository.NewPaymentMethodRepository(dbi.DB)
svc := NewPaymentMethodService(methodRepo)
method, err := svc.CreateMethod(CreatePaymentMethodDTO{
SpaceID: "some-space",
Name: "",
Type: model.PaymentMethodTypeCredit,
LastFour: "4242",
CreatedBy: "some-user",
})
assert.Error(t, err)
assert.Nil(t, method)
})
}
func TestPaymentMethodService_CreateMethod_InvalidType(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
methodRepo := repository.NewPaymentMethodRepository(dbi.DB)
svc := NewPaymentMethodService(methodRepo)
method, err := svc.CreateMethod(CreatePaymentMethodDTO{
SpaceID: "some-space",
Name: "Bad Type Card",
Type: "invalid",
LastFour: "4242",
CreatedBy: "some-user",
})
assert.Error(t, err)
assert.Nil(t, method)
})
}
func TestPaymentMethodService_CreateMethod_InvalidLastFour(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
methodRepo := repository.NewPaymentMethodRepository(dbi.DB)
svc := NewPaymentMethodService(methodRepo)
method, err := svc.CreateMethod(CreatePaymentMethodDTO{
SpaceID: "some-space",
Name: "Short Digits Card",
Type: model.PaymentMethodTypeDebit,
LastFour: "12",
CreatedBy: "some-user",
})
assert.Error(t, err)
assert.Nil(t, method)
})
}
func TestPaymentMethodService_GetMethodsForSpace(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
methodRepo := repository.NewPaymentMethodRepository(dbi.DB)
svc := NewPaymentMethodService(methodRepo)
user := testutil.CreateTestUser(t, dbi.DB, "pm-svc-list@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "PM Svc List Space")
testutil.CreateTestPaymentMethod(t, dbi.DB, space.ID, "Visa", model.PaymentMethodTypeCredit, user.ID)
testutil.CreateTestPaymentMethod(t, dbi.DB, space.ID, "Debit", model.PaymentMethodTypeDebit, user.ID)
methods, err := svc.GetMethodsForSpace(space.ID)
require.NoError(t, err)
assert.Len(t, methods, 2)
})
}
func TestPaymentMethodService_UpdateMethod(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
methodRepo := repository.NewPaymentMethodRepository(dbi.DB)
svc := NewPaymentMethodService(methodRepo)
user := testutil.CreateTestUser(t, dbi.DB, "pm-svc-update@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "PM Svc Update Space")
method := testutil.CreateTestPaymentMethod(t, dbi.DB, space.ID, "Old Card", model.PaymentMethodTypeCredit, user.ID)
updated, err := svc.UpdateMethod(UpdatePaymentMethodDTO{
ID: method.ID,
Name: "New Card",
Type: model.PaymentMethodTypeDebit,
LastFour: "9999",
})
require.NoError(t, err)
assert.Equal(t, "New Card", updated.Name)
assert.Equal(t, model.PaymentMethodTypeDebit, updated.Type)
require.NotNil(t, updated.LastFour)
assert.Equal(t, "9999", *updated.LastFour)
})
}
func TestPaymentMethodService_DeleteMethod(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
methodRepo := repository.NewPaymentMethodRepository(dbi.DB)
svc := NewPaymentMethodService(methodRepo)
user := testutil.CreateTestUser(t, dbi.DB, "pm-svc-delete@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "PM Svc Delete Space")
method := testutil.CreateTestPaymentMethod(t, dbi.DB, space.ID, "Doomed Card", model.PaymentMethodTypeCredit, user.ID)
err := svc.DeleteMethod(method.ID)
require.NoError(t, err)
methods, err := svc.GetMethodsForSpace(space.ID)
require.NoError(t, err)
assert.Empty(t, methods)
})
}

View file

@ -1,32 +0,0 @@
package service
import (
"errors"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/repository"
)
var ErrInvalidTimezone = errors.New("invalid timezone")
type ProfileService struct {
profileRepository repository.ProfileRepository
}
func NewProfileService(profileRepository repository.ProfileRepository) *ProfileService {
return &ProfileService{
profileRepository: profileRepository,
}
}
func (s *ProfileService) ByUserID(userID string) (*model.Profile, error) {
return s.profileRepository.ByUserID(userID)
}
func (s *ProfileService) UpdateTimezone(userID, timezone string) error {
if _, err := time.LoadLocation(timezone); err != nil {
return ErrInvalidTimezone
}
return s.profileRepository.UpdateTimezone(userID, timezone)
}

View file

@ -1,35 +0,0 @@
package service
import (
"testing"
"git.juancwu.dev/juancwu/budgit/internal/repository"
"git.juancwu.dev/juancwu/budgit/internal/testutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestProfileService_ByUserID(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
profileRepo := repository.NewProfileRepository(dbi.DB)
svc := NewProfileService(profileRepo)
user, profile := testutil.CreateTestUserWithProfile(t, dbi.DB, "profile@example.com", "Test User")
got, err := svc.ByUserID(user.ID)
require.NoError(t, err)
assert.Equal(t, profile.ID, got.ID)
assert.Equal(t, user.ID, got.UserID)
assert.Equal(t, "Test User", got.Name)
})
}
func TestProfileService_ByUserID_NotFound(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
profileRepo := repository.NewProfileRepository(dbi.DB)
svc := NewProfileService(profileRepo)
_, err := svc.ByUserID("nonexistent-id")
assert.Error(t, err)
})
}

View file

@ -1,318 +0,0 @@
package service
import (
"fmt"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/repository"
"github.com/google/uuid"
"github.com/shopspring/decimal"
)
type FundingSourceDTO struct {
SourceType model.FundingSourceType
AccountID string
Amount decimal.Decimal
}
type CreateReceiptDTO struct {
LoanID string
SpaceID string
UserID string
Description string
TotalAmount decimal.Decimal
Date time.Time
FundingSources []FundingSourceDTO
RecurringReceiptID *string
}
type UpdateReceiptDTO struct {
ID string
SpaceID string
UserID string
Description string
TotalAmount decimal.Decimal
Date time.Time
FundingSources []FundingSourceDTO
}
const ReceiptsPerPage = 25
type ReceiptService struct {
receiptRepo repository.ReceiptRepository
loanRepo repository.LoanRepository
accountRepo repository.MoneyAccountRepository
}
func NewReceiptService(
receiptRepo repository.ReceiptRepository,
loanRepo repository.LoanRepository,
accountRepo repository.MoneyAccountRepository,
) *ReceiptService {
return &ReceiptService{
receiptRepo: receiptRepo,
loanRepo: loanRepo,
accountRepo: accountRepo,
}
}
func (s *ReceiptService) CreateReceipt(dto CreateReceiptDTO) (*model.ReceiptWithSources, error) {
if dto.TotalAmount.LessThanOrEqual(decimal.Zero) {
return nil, fmt.Errorf("amount must be positive")
}
if len(dto.FundingSources) == 0 {
return nil, fmt.Errorf("at least one funding source is required")
}
// Validate funding sources sum to total
sum := decimal.Zero
for _, src := range dto.FundingSources {
if src.Amount.LessThanOrEqual(decimal.Zero) {
return nil, fmt.Errorf("each funding source amount must be positive")
}
sum = sum.Add(src.Amount)
}
if !sum.Equal(dto.TotalAmount) {
return nil, fmt.Errorf("funding source amounts (%s) must equal total amount (%s)", sum, dto.TotalAmount)
}
// Validate loan exists and is not paid off
loan, err := s.loanRepo.GetByID(dto.LoanID)
if err != nil {
return nil, fmt.Errorf("loan not found: %w", err)
}
if loan.IsPaidOff {
return nil, fmt.Errorf("loan is already paid off")
}
now := time.Now()
receipt := &model.Receipt{
ID: uuid.NewString(),
LoanID: dto.LoanID,
SpaceID: dto.SpaceID,
Description: dto.Description,
TotalAmount: dto.TotalAmount,
Date: dto.Date,
RecurringReceiptID: dto.RecurringReceiptID,
CreatedBy: dto.UserID,
CreatedAt: now,
UpdatedAt: now,
}
sources, balanceExpense, accountTransfers := s.buildLinkedRecords(receipt, dto.FundingSources, dto.SpaceID, dto.UserID, dto.Description, dto.Date)
if err := s.receiptRepo.CreateWithSources(receipt, sources, balanceExpense, accountTransfers); err != nil {
return nil, err
}
// Check if loan is now fully paid off
totalPaid, err := s.loanRepo.GetTotalPaidForLoan(dto.LoanID)
if err == nil && totalPaid.GreaterThanOrEqual(loan.OriginalAmount) {
_ = s.loanRepo.SetPaidOff(loan.ID, true)
}
return &model.ReceiptWithSources{Receipt: *receipt, Sources: sources}, nil
}
func (s *ReceiptService) buildLinkedRecords(
receipt *model.Receipt,
fundingSources []FundingSourceDTO,
spaceID, userID, description string,
date time.Time,
) ([]model.ReceiptFundingSource, *model.Expense, []*model.AccountTransfer) {
now := time.Now()
var sources []model.ReceiptFundingSource
var balanceExpense *model.Expense
var accountTransfers []*model.AccountTransfer
for _, src := range fundingSources {
fs := model.ReceiptFundingSource{
ID: uuid.NewString(),
ReceiptID: receipt.ID,
SourceType: src.SourceType,
Amount: src.Amount,
}
if src.SourceType == model.FundingSourceBalance {
expense := &model.Expense{
ID: uuid.NewString(),
SpaceID: spaceID,
CreatedBy: userID,
Description: fmt.Sprintf("Loan payment: %s", description),
Amount: src.Amount,
Type: model.ExpenseTypeExpense,
Date: date,
CreatedAt: now,
UpdatedAt: now,
}
balanceExpense = expense
fs.LinkedExpenseID = &expense.ID
} else {
acctID := src.AccountID
fs.AccountID = &acctID
transfer := &model.AccountTransfer{
ID: uuid.NewString(),
AccountID: src.AccountID,
Amount: src.Amount,
Direction: model.TransferDirectionWithdrawal,
Note: fmt.Sprintf("Loan payment: %s", description),
CreatedBy: userID,
CreatedAt: now,
}
accountTransfers = append(accountTransfers, transfer)
fs.LinkedTransferID = &transfer.ID
}
sources = append(sources, fs)
}
return sources, balanceExpense, accountTransfers
}
func (s *ReceiptService) GetReceipt(id string) (*model.ReceiptWithSourcesAndAccounts, error) {
receipt, err := s.receiptRepo.GetByID(id)
if err != nil {
return nil, err
}
sourcesMap, err := s.receiptRepo.GetFundingSourcesWithAccountsByReceiptIDs([]string{id})
if err != nil {
return nil, err
}
return &model.ReceiptWithSourcesAndAccounts{
Receipt: *receipt,
Sources: sourcesMap[id],
}, nil
}
func (s *ReceiptService) GetReceiptsForLoanPaginated(loanID string, page int) ([]*model.ReceiptWithSourcesAndAccounts, int, error) {
total, err := s.receiptRepo.CountByLoanID(loanID)
if err != nil {
return nil, 0, err
}
totalPages := (total + ReceiptsPerPage - 1) / ReceiptsPerPage
if totalPages < 1 {
totalPages = 1
}
if page < 1 {
page = 1
}
if page > totalPages {
page = totalPages
}
offset := (page - 1) * ReceiptsPerPage
receipts, err := s.receiptRepo.GetByLoanIDPaginated(loanID, ReceiptsPerPage, offset)
if err != nil {
return nil, 0, err
}
return s.attachSources(receipts, totalPages)
}
func (s *ReceiptService) attachSources(receipts []*model.Receipt, totalPages int) ([]*model.ReceiptWithSourcesAndAccounts, int, error) {
ids := make([]string, len(receipts))
for i, r := range receipts {
ids[i] = r.ID
}
sourcesMap, err := s.receiptRepo.GetFundingSourcesWithAccountsByReceiptIDs(ids)
if err != nil {
return nil, 0, err
}
result := make([]*model.ReceiptWithSourcesAndAccounts, len(receipts))
for i, r := range receipts {
result[i] = &model.ReceiptWithSourcesAndAccounts{
Receipt: *r,
Sources: sourcesMap[r.ID],
}
}
return result, totalPages, nil
}
func (s *ReceiptService) DeleteReceipt(id string, spaceID string) error {
receipt, err := s.receiptRepo.GetByID(id)
if err != nil {
return err
}
if receipt.SpaceID != spaceID {
return fmt.Errorf("receipt not found")
}
if err := s.receiptRepo.DeleteWithReversal(id); err != nil {
return err
}
// Check if loan should be un-marked as paid off
totalPaid, err := s.loanRepo.GetTotalPaidForLoan(receipt.LoanID)
if err != nil {
return nil // receipt deleted successfully, paid-off check is best-effort
}
loan, err := s.loanRepo.GetByID(receipt.LoanID)
if err != nil {
return nil
}
if loan.IsPaidOff && totalPaid.LessThan(loan.OriginalAmount) {
_ = s.loanRepo.SetPaidOff(loan.ID, false)
}
return nil
}
func (s *ReceiptService) UpdateReceipt(dto UpdateReceiptDTO) (*model.ReceiptWithSources, error) {
if dto.TotalAmount.LessThanOrEqual(decimal.Zero) {
return nil, fmt.Errorf("amount must be positive")
}
if len(dto.FundingSources) == 0 {
return nil, fmt.Errorf("at least one funding source is required")
}
sum := decimal.Zero
for _, src := range dto.FundingSources {
if src.Amount.LessThanOrEqual(decimal.Zero) {
return nil, fmt.Errorf("each funding source amount must be positive")
}
sum = sum.Add(src.Amount)
}
if !sum.Equal(dto.TotalAmount) {
return nil, fmt.Errorf("funding source amounts (%s) must equal total amount (%s)", sum, dto.TotalAmount)
}
existing, err := s.receiptRepo.GetByID(dto.ID)
if err != nil {
return nil, err
}
if existing.SpaceID != dto.SpaceID {
return nil, fmt.Errorf("receipt not found")
}
existing.Description = dto.Description
existing.TotalAmount = dto.TotalAmount
existing.Date = dto.Date
existing.UpdatedAt = time.Now()
sources, balanceExpense, accountTransfers := s.buildLinkedRecords(existing, dto.FundingSources, dto.SpaceID, dto.UserID, dto.Description, dto.Date)
if err := s.receiptRepo.UpdateWithSources(existing, sources, balanceExpense, accountTransfers); err != nil {
return nil, err
}
// Re-check paid-off status
loan, err := s.loanRepo.GetByID(existing.LoanID)
if err == nil {
totalPaid, err := s.loanRepo.GetTotalPaidForLoan(existing.LoanID)
if err == nil {
if totalPaid.GreaterThanOrEqual(loan.OriginalAmount) && !loan.IsPaidOff {
_ = s.loanRepo.SetPaidOff(loan.ID, true)
} else if totalPaid.LessThan(loan.OriginalAmount) && loan.IsPaidOff {
_ = s.loanRepo.SetPaidOff(loan.ID, false)
}
}
}
return &model.ReceiptWithSources{Receipt: *existing, Sources: sources}, nil
}

View file

@ -1,304 +0,0 @@
package service
import (
"fmt"
"log/slog"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/repository"
"github.com/google/uuid"
"github.com/shopspring/decimal"
)
type CreateRecurringExpenseDTO struct {
SpaceID string
UserID string
Description string
Amount decimal.Decimal
Type model.ExpenseType
PaymentMethodID *string
Frequency model.Frequency
StartDate time.Time
EndDate *time.Time
TagIDs []string
}
type UpdateRecurringExpenseDTO struct {
ID string
Description string
Amount decimal.Decimal
Type model.ExpenseType
PaymentMethodID *string
Frequency model.Frequency
StartDate time.Time
EndDate *time.Time
TagIDs []string
}
type RecurringExpenseService struct {
recurringRepo repository.RecurringExpenseRepository
expenseRepo repository.ExpenseRepository
profileRepo repository.ProfileRepository
spaceRepo repository.SpaceRepository
}
func NewRecurringExpenseService(recurringRepo repository.RecurringExpenseRepository, expenseRepo repository.ExpenseRepository, profileRepo repository.ProfileRepository, spaceRepo repository.SpaceRepository) *RecurringExpenseService {
return &RecurringExpenseService{
recurringRepo: recurringRepo,
expenseRepo: expenseRepo,
profileRepo: profileRepo,
spaceRepo: spaceRepo,
}
}
func (s *RecurringExpenseService) CreateRecurringExpense(dto CreateRecurringExpenseDTO) (*model.RecurringExpense, error) {
if dto.Description == "" {
return nil, fmt.Errorf("description cannot be empty")
}
if dto.Amount.LessThanOrEqual(decimal.Zero) {
return nil, fmt.Errorf("amount must be positive")
}
now := time.Now()
re := &model.RecurringExpense{
ID: uuid.NewString(),
SpaceID: dto.SpaceID,
CreatedBy: dto.UserID,
Description: dto.Description,
Amount: dto.Amount,
Type: dto.Type,
PaymentMethodID: dto.PaymentMethodID,
Frequency: dto.Frequency,
StartDate: dto.StartDate,
EndDate: dto.EndDate,
NextOccurrence: dto.StartDate,
IsActive: true,
CreatedAt: now,
UpdatedAt: now,
}
if err := s.recurringRepo.Create(re, dto.TagIDs); err != nil {
return nil, err
}
return re, nil
}
func (s *RecurringExpenseService) GetRecurringExpense(id string) (*model.RecurringExpense, error) {
return s.recurringRepo.GetByID(id)
}
func (s *RecurringExpenseService) GetRecurringExpensesForSpace(spaceID string) ([]*model.RecurringExpense, error) {
return s.recurringRepo.GetBySpaceID(spaceID)
}
func (s *RecurringExpenseService) GetRecurringExpensesWithTagsAndMethodsForSpace(spaceID string) ([]*model.RecurringExpenseWithTagsAndMethod, error) {
recs, err := s.recurringRepo.GetBySpaceID(spaceID)
if err != nil {
return nil, err
}
ids := make([]string, len(recs))
for i, re := range recs {
ids[i] = re.ID
}
tagsMap, err := s.recurringRepo.GetTagsByRecurringExpenseIDs(ids)
if err != nil {
return nil, err
}
methodsMap, err := s.recurringRepo.GetPaymentMethodsByRecurringExpenseIDs(ids)
if err != nil {
return nil, err
}
result := make([]*model.RecurringExpenseWithTagsAndMethod, len(recs))
for i, re := range recs {
result[i] = &model.RecurringExpenseWithTagsAndMethod{
RecurringExpense: *re,
Tags: tagsMap[re.ID],
PaymentMethod: methodsMap[re.ID],
}
}
return result, nil
}
func (s *RecurringExpenseService) UpdateRecurringExpense(dto UpdateRecurringExpenseDTO) (*model.RecurringExpense, error) {
if dto.Description == "" {
return nil, fmt.Errorf("description cannot be empty")
}
if dto.Amount.LessThanOrEqual(decimal.Zero) {
return nil, fmt.Errorf("amount must be positive")
}
existing, err := s.recurringRepo.GetByID(dto.ID)
if err != nil {
return nil, err
}
existing.Description = dto.Description
existing.Amount = dto.Amount
existing.Type = dto.Type
existing.PaymentMethodID = dto.PaymentMethodID
existing.Frequency = dto.Frequency
existing.StartDate = dto.StartDate
existing.EndDate = dto.EndDate
existing.UpdatedAt = time.Now()
// Recalculate next occurrence if frequency or start changed
if existing.NextOccurrence.Before(dto.StartDate) {
existing.NextOccurrence = dto.StartDate
}
if err := s.recurringRepo.Update(existing, dto.TagIDs); err != nil {
return nil, err
}
return existing, nil
}
func (s *RecurringExpenseService) DeleteRecurringExpense(id string) error {
return s.recurringRepo.Delete(id)
}
func (s *RecurringExpenseService) ToggleRecurringExpense(id string) (*model.RecurringExpense, error) {
re, err := s.recurringRepo.GetByID(id)
if err != nil {
return nil, err
}
newActive := !re.IsActive
if err := s.recurringRepo.SetActive(id, newActive); err != nil {
return nil, err
}
re.IsActive = newActive
return re, nil
}
func (s *RecurringExpenseService) ProcessDueRecurrences(now time.Time) error {
dues, err := s.recurringRepo.GetDueRecurrences(now)
if err != nil {
return fmt.Errorf("failed to get due recurrences: %w", err)
}
tzCache := make(map[string]*time.Location)
for _, re := range dues {
localNow := s.getLocalNow(re.SpaceID, re.CreatedBy, now, tzCache)
if err := s.processRecurrence(re, localNow); err != nil {
slog.Error("failed to process recurring expense", "id", re.ID, "error", err)
}
}
return nil
}
func (s *RecurringExpenseService) ProcessDueRecurrencesForSpace(spaceID string, now time.Time) error {
dues, err := s.recurringRepo.GetDueRecurrencesForSpace(spaceID, now)
if err != nil {
return fmt.Errorf("failed to get due recurrences for space: %w", err)
}
tzCache := make(map[string]*time.Location)
for _, re := range dues {
localNow := s.getLocalNow(re.SpaceID, re.CreatedBy, now, tzCache)
if err := s.processRecurrence(re, localNow); err != nil {
slog.Error("failed to process recurring expense", "id", re.ID, "error", err)
}
}
return nil
}
func (s *RecurringExpenseService) processRecurrence(re *model.RecurringExpense, now time.Time) error {
// Get tag IDs for this recurring expense
tagsMap, err := s.recurringRepo.GetTagsByRecurringExpenseIDs([]string{re.ID})
if err != nil {
return err
}
var tagIDs []string
for _, t := range tagsMap[re.ID] {
tagIDs = append(tagIDs, t.ID)
}
// Generate expenses for each missed occurrence up to now
for !re.NextOccurrence.After(now) {
// Check if end_date has been passed
if re.EndDate != nil && re.NextOccurrence.After(*re.EndDate) {
return s.recurringRepo.Deactivate(re.ID)
}
expense := &model.Expense{
ID: uuid.NewString(),
SpaceID: re.SpaceID,
CreatedBy: re.CreatedBy,
Description: re.Description,
Amount: re.Amount,
Type: re.Type,
Date: re.NextOccurrence,
PaymentMethodID: re.PaymentMethodID,
RecurringExpenseID: &re.ID,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := s.expenseRepo.Create(expense, tagIDs, nil); err != nil {
return fmt.Errorf("failed to create expense from recurring: %w", err)
}
re.NextOccurrence = AdvanceDate(re.NextOccurrence, re.Frequency)
}
// Check if the new next occurrence exceeds end date
if re.EndDate != nil && re.NextOccurrence.After(*re.EndDate) {
if err := s.recurringRepo.Deactivate(re.ID); err != nil {
return err
}
}
return s.recurringRepo.UpdateNextOccurrence(re.ID, re.NextOccurrence)
}
// getLocalNow resolves the effective timezone for a recurring expense.
// Resolution order: space timezone → user profile timezone → UTC.
func (s *RecurringExpenseService) getLocalNow(spaceID, userID string, now time.Time, cache map[string]*time.Location) time.Time {
spaceKey := "space:" + spaceID
if loc, ok := cache[spaceKey]; ok {
return now.In(loc)
}
space, err := s.spaceRepo.ByID(spaceID)
if err == nil && space != nil {
if loc := space.Location(); loc != nil {
cache[spaceKey] = loc
return now.In(loc)
}
}
userKey := "user:" + userID
if loc, ok := cache[userKey]; ok {
return now.In(loc)
}
loc := time.UTC
profile, err := s.profileRepo.ByUserID(userID)
if err == nil && profile != nil {
loc = profile.Location()
}
cache[userKey] = loc
return now.In(loc)
}
func AdvanceDate(date time.Time, freq model.Frequency) time.Time {
switch freq {
case model.FrequencyDaily:
return date.AddDate(0, 0, 1)
case model.FrequencyWeekly:
return date.AddDate(0, 0, 7)
case model.FrequencyBiweekly:
return date.AddDate(0, 0, 14)
case model.FrequencyMonthly:
return date.AddDate(0, 1, 0)
case model.FrequencyYearly:
return date.AddDate(1, 0, 0)
default:
return date.AddDate(0, 1, 0)
}
}

View file

@ -1,324 +0,0 @@
package service
import (
"fmt"
"log/slog"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/repository"
"github.com/google/uuid"
"github.com/shopspring/decimal"
)
type CreateRecurringReceiptDTO struct {
LoanID string
SpaceID string
UserID string
Description string
TotalAmount decimal.Decimal
Frequency model.Frequency
StartDate time.Time
EndDate *time.Time
FundingSources []FundingSourceDTO
}
type UpdateRecurringReceiptDTO struct {
ID string
Description string
TotalAmount decimal.Decimal
Frequency model.Frequency
StartDate time.Time
EndDate *time.Time
FundingSources []FundingSourceDTO
}
type RecurringReceiptService struct {
recurringRepo repository.RecurringReceiptRepository
receiptService *ReceiptService
loanRepo repository.LoanRepository
profileRepo repository.ProfileRepository
spaceRepo repository.SpaceRepository
}
func NewRecurringReceiptService(
recurringRepo repository.RecurringReceiptRepository,
receiptService *ReceiptService,
loanRepo repository.LoanRepository,
profileRepo repository.ProfileRepository,
spaceRepo repository.SpaceRepository,
) *RecurringReceiptService {
return &RecurringReceiptService{
recurringRepo: recurringRepo,
receiptService: receiptService,
loanRepo: loanRepo,
profileRepo: profileRepo,
spaceRepo: spaceRepo,
}
}
func (s *RecurringReceiptService) CreateRecurringReceipt(dto CreateRecurringReceiptDTO) (*model.RecurringReceiptWithSources, error) {
if dto.TotalAmount.LessThanOrEqual(decimal.Zero) {
return nil, fmt.Errorf("amount must be positive")
}
if len(dto.FundingSources) == 0 {
return nil, fmt.Errorf("at least one funding source is required")
}
sum := decimal.Zero
for _, src := range dto.FundingSources {
sum = sum.Add(src.Amount)
}
if !sum.Equal(dto.TotalAmount) {
return nil, fmt.Errorf("funding source amounts must equal total amount")
}
now := time.Now()
rr := &model.RecurringReceipt{
ID: uuid.NewString(),
LoanID: dto.LoanID,
SpaceID: dto.SpaceID,
Description: dto.Description,
TotalAmount: dto.TotalAmount,
Frequency: dto.Frequency,
StartDate: dto.StartDate,
EndDate: dto.EndDate,
NextOccurrence: dto.StartDate,
IsActive: true,
CreatedBy: dto.UserID,
CreatedAt: now,
UpdatedAt: now,
}
sources := make([]model.RecurringReceiptSource, len(dto.FundingSources))
for i, src := range dto.FundingSources {
sources[i] = model.RecurringReceiptSource{
ID: uuid.NewString(),
RecurringReceiptID: rr.ID,
SourceType: src.SourceType,
Amount: src.Amount,
}
if src.SourceType == model.FundingSourceAccount {
acctID := src.AccountID
sources[i].AccountID = &acctID
}
}
if err := s.recurringRepo.Create(rr, sources); err != nil {
return nil, err
}
return &model.RecurringReceiptWithSources{
RecurringReceipt: *rr,
Sources: sources,
}, nil
}
func (s *RecurringReceiptService) GetRecurringReceipt(id string) (*model.RecurringReceipt, error) {
return s.recurringRepo.GetByID(id)
}
func (s *RecurringReceiptService) GetRecurringReceiptsForLoan(loanID string) ([]*model.RecurringReceipt, error) {
return s.recurringRepo.GetByLoanID(loanID)
}
func (s *RecurringReceiptService) GetRecurringReceiptsWithSourcesForLoan(loanID string) ([]*model.RecurringReceiptWithSources, error) {
rrs, err := s.recurringRepo.GetByLoanID(loanID)
if err != nil {
return nil, err
}
result := make([]*model.RecurringReceiptWithSources, len(rrs))
for i, rr := range rrs {
sources, err := s.recurringRepo.GetSourcesByRecurringReceiptID(rr.ID)
if err != nil {
return nil, err
}
result[i] = &model.RecurringReceiptWithSources{
RecurringReceipt: *rr,
Sources: sources,
}
}
return result, nil
}
func (s *RecurringReceiptService) UpdateRecurringReceipt(dto UpdateRecurringReceiptDTO) (*model.RecurringReceipt, error) {
if dto.TotalAmount.LessThanOrEqual(decimal.Zero) {
return nil, fmt.Errorf("amount must be positive")
}
existing, err := s.recurringRepo.GetByID(dto.ID)
if err != nil {
return nil, err
}
existing.Description = dto.Description
existing.TotalAmount = dto.TotalAmount
existing.Frequency = dto.Frequency
existing.StartDate = dto.StartDate
existing.EndDate = dto.EndDate
existing.UpdatedAt = time.Now()
if existing.NextOccurrence.Before(dto.StartDate) {
existing.NextOccurrence = dto.StartDate
}
sources := make([]model.RecurringReceiptSource, len(dto.FundingSources))
for i, src := range dto.FundingSources {
sources[i] = model.RecurringReceiptSource{
ID: uuid.NewString(),
RecurringReceiptID: existing.ID,
SourceType: src.SourceType,
Amount: src.Amount,
}
if src.SourceType == model.FundingSourceAccount {
acctID := src.AccountID
sources[i].AccountID = &acctID
}
}
if err := s.recurringRepo.Update(existing, sources); err != nil {
return nil, err
}
return existing, nil
}
func (s *RecurringReceiptService) DeleteRecurringReceipt(id string) error {
return s.recurringRepo.Delete(id)
}
func (s *RecurringReceiptService) ToggleRecurringReceipt(id string) (*model.RecurringReceipt, error) {
rr, err := s.recurringRepo.GetByID(id)
if err != nil {
return nil, err
}
newActive := !rr.IsActive
if err := s.recurringRepo.SetActive(id, newActive); err != nil {
return nil, err
}
rr.IsActive = newActive
return rr, nil
}
func (s *RecurringReceiptService) ProcessDueRecurrences(now time.Time) error {
dues, err := s.recurringRepo.GetDueRecurrences(now)
if err != nil {
return fmt.Errorf("failed to get due recurring receipts: %w", err)
}
tzCache := make(map[string]*time.Location)
for _, rr := range dues {
localNow := s.getLocalNow(rr.SpaceID, rr.CreatedBy, now, tzCache)
if err := s.processRecurrence(rr, localNow); err != nil {
slog.Error("failed to process recurring receipt", "id", rr.ID, "error", err)
}
}
return nil
}
func (s *RecurringReceiptService) ProcessDueRecurrencesForSpace(spaceID string, now time.Time) error {
dues, err := s.recurringRepo.GetDueRecurrencesForSpace(spaceID, now)
if err != nil {
return fmt.Errorf("failed to get due recurring receipts for space: %w", err)
}
tzCache := make(map[string]*time.Location)
for _, rr := range dues {
localNow := s.getLocalNow(rr.SpaceID, rr.CreatedBy, now, tzCache)
if err := s.processRecurrence(rr, localNow); err != nil {
slog.Error("failed to process recurring receipt", "id", rr.ID, "error", err)
}
}
return nil
}
func (s *RecurringReceiptService) processRecurrence(rr *model.RecurringReceipt, now time.Time) error {
sources, err := s.recurringRepo.GetSourcesByRecurringReceiptID(rr.ID)
if err != nil {
return err
}
for !rr.NextOccurrence.After(now) {
if rr.EndDate != nil && rr.NextOccurrence.After(*rr.EndDate) {
return s.recurringRepo.Deactivate(rr.ID)
}
// Check if loan is already paid off
loan, err := s.loanRepo.GetByID(rr.LoanID)
if err != nil {
return fmt.Errorf("failed to get loan: %w", err)
}
if loan.IsPaidOff {
return s.recurringRepo.Deactivate(rr.ID)
}
// Build funding source DTOs from template
fundingSources := make([]FundingSourceDTO, len(sources))
for i, src := range sources {
accountID := ""
if src.AccountID != nil {
accountID = *src.AccountID
}
fundingSources[i] = FundingSourceDTO{
SourceType: src.SourceType,
AccountID: accountID,
Amount: src.Amount,
}
}
rrID := rr.ID
dto := CreateReceiptDTO{
LoanID: rr.LoanID,
SpaceID: rr.SpaceID,
UserID: rr.CreatedBy,
Description: rr.Description,
TotalAmount: rr.TotalAmount,
Date: rr.NextOccurrence,
FundingSources: fundingSources,
RecurringReceiptID: &rrID,
}
if _, err := s.receiptService.CreateReceipt(dto); err != nil {
slog.Warn("recurring receipt skipped", "id", rr.ID, "error", err)
}
rr.NextOccurrence = AdvanceDate(rr.NextOccurrence, rr.Frequency)
}
if rr.EndDate != nil && rr.NextOccurrence.After(*rr.EndDate) {
if err := s.recurringRepo.Deactivate(rr.ID); err != nil {
return err
}
}
return s.recurringRepo.UpdateNextOccurrence(rr.ID, rr.NextOccurrence)
}
func (s *RecurringReceiptService) getLocalNow(spaceID, userID string, now time.Time, cache map[string]*time.Location) time.Time {
spaceKey := "space:" + spaceID
if loc, ok := cache[spaceKey]; ok {
return now.In(loc)
}
space, err := s.spaceRepo.ByID(spaceID)
if err == nil && space != nil {
if loc := space.Location(); loc != nil {
cache[spaceKey] = loc
return now.In(loc)
}
}
userKey := "user:" + userID
if loc, ok := cache[userKey]; ok {
return now.In(loc)
}
loc := time.UTC
profile, err := s.profileRepo.ByUserID(userID)
if err == nil && profile != nil {
loc = profile.Location()
}
cache[userKey] = loc
return now.In(loc)
}

View file

@ -1,105 +0,0 @@
package service
import (
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/repository"
)
type ReportService struct {
expenseRepo repository.ExpenseRepository
}
func NewReportService(expenseRepo repository.ExpenseRepository) *ReportService {
return &ReportService{expenseRepo: expenseRepo}
}
type DateRange struct {
Label string
Key string
From time.Time
To time.Time
}
func (s *ReportService) GetSpendingReport(spaceID string, from, to time.Time) (*model.SpendingReport, error) {
byTag, err := s.expenseRepo.GetExpensesByTag(spaceID, from, to)
if err != nil {
return nil, err
}
daily, err := s.expenseRepo.GetDailySpending(spaceID, from, to)
if err != nil {
return nil, err
}
monthly, err := s.expenseRepo.GetMonthlySpending(spaceID, from, to)
if err != nil {
return nil, err
}
topExpenses, err := s.expenseRepo.GetTopExpenses(spaceID, from, to, 10)
if err != nil {
return nil, err
}
// Get tags and payment methods for top expenses
ids := make([]string, len(topExpenses))
for i, e := range topExpenses {
ids[i] = e.ID
}
tagsMap, _ := s.expenseRepo.GetTagsByExpenseIDs(ids)
methodsMap, _ := s.expenseRepo.GetPaymentMethodsByExpenseIDs(ids)
topWithTags := make([]*model.ExpenseWithTagsAndMethod, len(topExpenses))
for i, e := range topExpenses {
topWithTags[i] = &model.ExpenseWithTagsAndMethod{
Expense: *e,
Tags: tagsMap[e.ID],
PaymentMethod: methodsMap[e.ID],
}
}
byPaymentMethod, err := s.expenseRepo.GetExpensesByPaymentMethod(spaceID, from, to)
if err != nil {
return nil, err
}
totalIncome, totalExpenses, err := s.expenseRepo.GetIncomeVsExpenseSummary(spaceID, from, to)
if err != nil {
return nil, err
}
return &model.SpendingReport{
ByTag: byTag,
ByPaymentMethod: byPaymentMethod,
DailySpending: daily,
MonthlySpending: monthly,
TopExpenses: topWithTags,
TotalIncome: totalIncome,
TotalExpenses: totalExpenses,
NetBalance: totalIncome.Sub(totalExpenses),
}, nil
}
func GetPresetDateRanges(now time.Time) []DateRange {
thisMonthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
thisMonthEnd := thisMonthStart.AddDate(0, 1, -1)
thisMonthEnd = time.Date(thisMonthEnd.Year(), thisMonthEnd.Month(), thisMonthEnd.Day(), 23, 59, 59, 0, now.Location())
lastMonthStart := thisMonthStart.AddDate(0, -1, 0)
lastMonthEnd := thisMonthStart.AddDate(0, 0, -1)
lastMonthEnd = time.Date(lastMonthEnd.Year(), lastMonthEnd.Month(), lastMonthEnd.Day(), 23, 59, 59, 0, now.Location())
last3MonthsStart := thisMonthStart.AddDate(0, -2, 0)
yearStart := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, now.Location())
return []DateRange{
{Label: "This Month", Key: "this_month", From: thisMonthStart, To: thisMonthEnd},
{Label: "Last Month", Key: "last_month", From: lastMonthStart, To: lastMonthEnd},
{Label: "Last 3 Months", Key: "last_3_months", From: last3MonthsStart, To: thisMonthEnd},
{Label: "This Year", Key: "this_year", From: yearStart, To: thisMonthEnd},
}
}

View file

@ -1,205 +0,0 @@
package service
import (
"fmt"
"strings"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/repository"
"github.com/google/uuid"
)
type ShoppingListService struct {
listRepo repository.ShoppingListRepository
itemRepo repository.ListItemRepository
}
func NewShoppingListService(listRepo repository.ShoppingListRepository, itemRepo repository.ListItemRepository) *ShoppingListService {
return &ShoppingListService{
listRepo: listRepo,
itemRepo: itemRepo,
}
}
// List methods
func (s *ShoppingListService) CreateList(spaceID, name string) (*model.ShoppingList, error) {
name = strings.TrimSpace(name)
if name == "" {
return nil, fmt.Errorf("list name cannot be empty")
}
now := time.Now()
list := &model.ShoppingList{
ID: uuid.NewString(),
SpaceID: spaceID,
Name: name,
CreatedAt: now,
UpdatedAt: now,
}
err := s.listRepo.Create(list)
if err != nil {
return nil, err
}
return list, nil
}
func (s *ShoppingListService) GetListsForSpace(spaceID string) ([]*model.ShoppingList, error) {
return s.listRepo.GetBySpaceID(spaceID)
}
func (s *ShoppingListService) GetList(listID string) (*model.ShoppingList, error) {
return s.listRepo.GetByID(listID)
}
func (s *ShoppingListService) UpdateList(listID, name string) (*model.ShoppingList, error) {
name = strings.TrimSpace(name)
if name == "" {
return nil, fmt.Errorf("list name cannot be empty")
}
list, err := s.listRepo.GetByID(listID)
if err != nil {
return nil, err
}
list.Name = name
err = s.listRepo.Update(list)
if err != nil {
return nil, err
}
return list, nil
}
func (s *ShoppingListService) DeleteList(listID string) error {
// First delete all items in the list
err := s.itemRepo.DeleteByListID(listID)
if err != nil {
return fmt.Errorf("failed to delete items in list: %w", err)
}
// Then delete the list itself
return s.listRepo.Delete(listID)
}
// Item methods
func (s *ShoppingListService) AddItemToList(listID, name, createdBy string) (*model.ListItem, error) {
name = strings.TrimSpace(name)
if name == "" {
return nil, fmt.Errorf("item name cannot be empty")
}
now := time.Now()
item := &model.ListItem{
ID: uuid.NewString(),
ListID: listID,
Name: name,
IsChecked: false,
CreatedBy: createdBy,
CreatedAt: now,
UpdatedAt: now,
}
err := s.itemRepo.Create(item)
if err != nil {
return nil, err
}
return item, nil
}
func (s *ShoppingListService) GetItem(itemID string) (*model.ListItem, error) {
return s.itemRepo.GetByID(itemID)
}
func (s *ShoppingListService) GetItemsForList(listID string) ([]*model.ListItem, error) {
return s.itemRepo.GetByListID(listID)
}
const ItemsPerCardPage = 5
func (s *ShoppingListService) GetItemsForListPaginated(listID string, page int) ([]*model.ListItem, int, error) {
total, err := s.itemRepo.CountByListID(listID)
if err != nil {
return nil, 0, err
}
page, totalPages, offset := Paginate(page, total, ItemsPerCardPage)
items, err := s.itemRepo.GetByListIDPaginated(listID, ItemsPerCardPage, offset)
if err != nil {
return nil, 0, err
}
return items, totalPages, nil
}
func (s *ShoppingListService) UpdateItem(itemID, name string, isChecked bool) (*model.ListItem, error) {
name = strings.TrimSpace(name)
if name == "" {
return nil, fmt.Errorf("item name cannot be empty")
}
item, err := s.itemRepo.GetByID(itemID)
if err != nil {
return nil, err
}
item.Name = name
item.IsChecked = isChecked
err = s.itemRepo.Update(item)
if err != nil {
return nil, err
}
return item, nil
}
func (s *ShoppingListService) CheckItem(itemID string) error {
item, err := s.itemRepo.GetByID(itemID)
if err != nil {
return err
}
item.IsChecked = true
return s.itemRepo.Update(item)
}
func (s *ShoppingListService) GetListsWithUncheckedItems(spaceID string) ([]model.ListWithUncheckedItems, error) {
lists, err := s.listRepo.GetBySpaceID(spaceID)
if err != nil {
return nil, err
}
var result []model.ListWithUncheckedItems
for _, list := range lists {
items, err := s.itemRepo.GetByListID(list.ID)
if err != nil {
return nil, err
}
var unchecked []*model.ListItem
for _, item := range items {
if !item.IsChecked {
unchecked = append(unchecked, item)
}
}
if len(unchecked) > 0 {
result = append(result, model.ListWithUncheckedItems{
List: list,
Items: unchecked,
})
}
}
return result, nil
}
func (s *ShoppingListService) DeleteItem(itemID string) error {
return s.itemRepo.Delete(itemID)
}

View file

@ -1,204 +0,0 @@
package service
import (
"fmt"
"testing"
"git.juancwu.dev/juancwu/budgit/internal/repository"
"git.juancwu.dev/juancwu/budgit/internal/testutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestShoppingListService_CreateList(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
listRepo := repository.NewShoppingListRepository(dbi.DB)
itemRepo := repository.NewListItemRepository(dbi.DB)
svc := NewShoppingListService(listRepo, itemRepo)
user := testutil.CreateTestUser(t, dbi.DB, "list-svc-create@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "List Svc Space")
list, err := svc.CreateList(space.ID, "Weekly Groceries")
require.NoError(t, err)
assert.NotEmpty(t, list.ID)
assert.Equal(t, "Weekly Groceries", list.Name)
assert.Equal(t, space.ID, list.SpaceID)
})
}
func TestShoppingListService_CreateList_EmptyName(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
listRepo := repository.NewShoppingListRepository(dbi.DB)
itemRepo := repository.NewListItemRepository(dbi.DB)
svc := NewShoppingListService(listRepo, itemRepo)
user := testutil.CreateTestUser(t, dbi.DB, "list-svc-empty@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "List Svc Empty Space")
list, err := svc.CreateList(space.ID, "")
assert.Error(t, err)
assert.Nil(t, list)
})
}
func TestShoppingListService_GetList(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
listRepo := repository.NewShoppingListRepository(dbi.DB)
itemRepo := repository.NewListItemRepository(dbi.DB)
svc := NewShoppingListService(listRepo, itemRepo)
user := testutil.CreateTestUser(t, dbi.DB, "list-svc-get@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "List Svc Get Space")
seeded := testutil.CreateTestShoppingList(t, dbi.DB, space.ID, "Seeded List")
list, err := svc.GetList(seeded.ID)
require.NoError(t, err)
assert.Equal(t, seeded.ID, list.ID)
assert.Equal(t, "Seeded List", list.Name)
})
}
func TestShoppingListService_UpdateList(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
listRepo := repository.NewShoppingListRepository(dbi.DB)
itemRepo := repository.NewListItemRepository(dbi.DB)
svc := NewShoppingListService(listRepo, itemRepo)
user := testutil.CreateTestUser(t, dbi.DB, "list-svc-update@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "List Svc Update Space")
seeded := testutil.CreateTestShoppingList(t, dbi.DB, space.ID, "Old Name")
updated, err := svc.UpdateList(seeded.ID, "New Name")
require.NoError(t, err)
assert.Equal(t, "New Name", updated.Name)
})
}
func TestShoppingListService_DeleteList(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
listRepo := repository.NewShoppingListRepository(dbi.DB)
itemRepo := repository.NewListItemRepository(dbi.DB)
svc := NewShoppingListService(listRepo, itemRepo)
user := testutil.CreateTestUser(t, dbi.DB, "list-svc-del@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "List Svc Del Space")
seeded := testutil.CreateTestShoppingList(t, dbi.DB, space.ID, "Doomed List")
testutil.CreateTestListItem(t, dbi.DB, seeded.ID, "Item 1", user.ID)
testutil.CreateTestListItem(t, dbi.DB, seeded.ID, "Item 2", user.ID)
err := svc.DeleteList(seeded.ID)
require.NoError(t, err)
_, err = svc.GetList(seeded.ID)
assert.Error(t, err)
items, err := itemRepo.GetByListID(seeded.ID)
require.NoError(t, err)
assert.Empty(t, items)
})
}
func TestShoppingListService_AddItemToList(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
listRepo := repository.NewShoppingListRepository(dbi.DB)
itemRepo := repository.NewListItemRepository(dbi.DB)
svc := NewShoppingListService(listRepo, itemRepo)
user := testutil.CreateTestUser(t, dbi.DB, "list-svc-additem@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "List Svc AddItem Space")
seeded := testutil.CreateTestShoppingList(t, dbi.DB, space.ID, "Add Item List")
item, err := svc.AddItemToList(seeded.ID, "Milk", user.ID)
require.NoError(t, err)
assert.NotEmpty(t, item.ID)
assert.Equal(t, "Milk", item.Name)
assert.Equal(t, seeded.ID, item.ListID)
assert.False(t, item.IsChecked)
})
}
func TestShoppingListService_GetItemsForListPaginated(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
listRepo := repository.NewShoppingListRepository(dbi.DB)
itemRepo := repository.NewListItemRepository(dbi.DB)
svc := NewShoppingListService(listRepo, itemRepo)
user := testutil.CreateTestUser(t, dbi.DB, "list-svc-paginate@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "List Svc Paginate Space")
seeded := testutil.CreateTestShoppingList(t, dbi.DB, space.ID, "Paginate List")
for i := 0; i < 6; i++ {
testutil.CreateTestListItem(t, dbi.DB, seeded.ID, fmt.Sprintf("Item %d", i), user.ID)
}
items, totalPages, err := svc.GetItemsForListPaginated(seeded.ID, 1)
require.NoError(t, err)
assert.Len(t, items, 5)
assert.Equal(t, 2, totalPages)
})
}
func TestShoppingListService_CheckItem(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
listRepo := repository.NewShoppingListRepository(dbi.DB)
itemRepo := repository.NewListItemRepository(dbi.DB)
svc := NewShoppingListService(listRepo, itemRepo)
user := testutil.CreateTestUser(t, dbi.DB, "list-svc-check@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "List Svc Check Space")
seeded := testutil.CreateTestShoppingList(t, dbi.DB, space.ID, "Check List")
item := testutil.CreateTestListItem(t, dbi.DB, seeded.ID, "Check Me", user.ID)
err := svc.CheckItem(item.ID)
require.NoError(t, err)
fetched, err := svc.GetItem(item.ID)
require.NoError(t, err)
assert.True(t, fetched.IsChecked)
})
}
func TestShoppingListService_GetListsWithUncheckedItems(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
listRepo := repository.NewShoppingListRepository(dbi.DB)
itemRepo := repository.NewListItemRepository(dbi.DB)
svc := NewShoppingListService(listRepo, itemRepo)
user := testutil.CreateTestUser(t, dbi.DB, "list-svc-unchecked@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "List Svc Unchecked Space")
seeded := testutil.CreateTestShoppingList(t, dbi.DB, space.ID, "Unchecked List")
checkedItem := testutil.CreateTestListItem(t, dbi.DB, seeded.ID, "Checked Item", user.ID)
testutil.CreateTestListItem(t, dbi.DB, seeded.ID, "Unchecked Item", user.ID)
_, err := dbi.DB.Exec("UPDATE list_items SET is_checked = true WHERE id = $1", checkedItem.ID)
require.NoError(t, err)
result, err := svc.GetListsWithUncheckedItems(space.ID)
require.NoError(t, err)
require.Len(t, result, 1)
assert.Equal(t, seeded.ID, result[0].List.ID)
require.Len(t, result[0].Items, 1)
assert.Equal(t, "Unchecked Item", result[0].Items[0].Name)
})
}
func TestShoppingListService_DeleteItem(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
listRepo := repository.NewShoppingListRepository(dbi.DB)
itemRepo := repository.NewListItemRepository(dbi.DB)
svc := NewShoppingListService(listRepo, itemRepo)
user := testutil.CreateTestUser(t, dbi.DB, "list-svc-delitem@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "List Svc DelItem Space")
seeded := testutil.CreateTestShoppingList(t, dbi.DB, space.ID, "DelItem List")
item := testutil.CreateTestListItem(t, dbi.DB, seeded.ID, "Doomed Item", user.ID)
err := svc.DeleteItem(item.ID)
require.NoError(t, err)
_, err = svc.GetItem(item.ID)
assert.Error(t, err)
})
}

View file

@ -111,13 +111,6 @@ func (s *SpaceService) UpdateSpaceName(spaceID, name string) error {
return s.spaceRepo.UpdateName(spaceID, name)
}
// UpdateSpaceTimezone updates the timezone of a space.
func (s *SpaceService) UpdateSpaceTimezone(spaceID, timezone string) error {
if _, err := time.LoadLocation(timezone); err != nil {
return ErrInvalidTimezone
}
return s.spaceRepo.UpdateTimezone(spaceID, timezone)
}
// DeleteSpace permanently deletes a space and all its associated data.
func (s *SpaceService) DeleteSpace(spaceID string) error {

View file

@ -98,8 +98,10 @@ func TestSpaceService_GetMembers(t *testing.T) {
spaceRepo := repository.NewSpaceRepository(dbi.DB)
svc := NewSpaceService(spaceRepo)
owner, _ := testutil.CreateTestUserWithProfile(t, dbi.DB, "owner-members@example.com", "Owner")
member, _ := testutil.CreateTestUserWithProfile(t, dbi.DB, "member-members@example.com", "Member")
ownerName := "Owner"
memberName := "Member"
owner := testutil.CreateTestUserWithName(t, dbi.DB, "owner-members@example.com", &ownerName)
member := testutil.CreateTestUserWithName(t, dbi.DB, "member-members@example.com", &memberName)
space := testutil.CreateTestSpace(t, dbi.DB, owner.ID, "Members Space")
// Add second user as a member
@ -115,9 +117,11 @@ func TestSpaceService_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,81 +0,0 @@
package service
import (
"fmt"
"strings"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/repository"
"github.com/google/uuid"
)
type TagService struct {
tagRepo repository.TagRepository
}
func NewTagService(tagRepo repository.TagRepository) *TagService {
return &TagService{tagRepo: tagRepo}
}
func NormalizeTagName(name string) string {
return strings.ToLower(strings.TrimSpace(name))
}
func (s *TagService) CreateTag(spaceID, name string, color *string) (*model.Tag, error) {
name = NormalizeTagName(name)
if name == "" {
return nil, fmt.Errorf("tag name cannot be empty")
}
now := time.Now()
tag := &model.Tag{
ID: uuid.NewString(),
SpaceID: spaceID,
Name: name,
Color: color,
CreatedAt: now,
UpdatedAt: now,
}
err := s.tagRepo.Create(tag)
if err != nil {
return nil, err
}
return tag, nil
}
func (s *TagService) GetTagsForSpace(spaceID string) ([]*model.Tag, error) {
return s.tagRepo.GetBySpaceID(spaceID)
}
func (s *TagService) GetTagByID(id string) (*model.Tag, error) {
return s.tagRepo.GetByID(id)
}
func (s *TagService) UpdateTag(id, name string, color *string) (*model.Tag, error) {
name = NormalizeTagName(name)
if name == "" {
return nil, fmt.Errorf("tag name cannot be empty")
}
tag, err := s.tagRepo.GetByID(id)
if err != nil {
return nil, err
}
tag.Name = name
tag.Color = color
err = s.tagRepo.Update(tag)
if err != nil {
return nil, err
}
return tag, nil
}
func (s *TagService) DeleteTag(id string) error {
return s.tagRepo.Delete(id)
}

View file

@ -1,99 +0,0 @@
package service
import (
"testing"
"git.juancwu.dev/juancwu/budgit/internal/repository"
"git.juancwu.dev/juancwu/budgit/internal/testutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestTagService_CreateTag(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
tagRepo := repository.NewTagRepository(dbi.DB)
svc := NewTagService(tagRepo)
user := testutil.CreateTestUser(t, dbi.DB, "tag-svc-create@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Tag Svc Space")
color := "#ff0000"
tag, err := svc.CreateTag(space.ID, "Groceries", &color)
require.NoError(t, err)
assert.NotEmpty(t, tag.ID)
assert.Equal(t, "groceries", tag.Name)
assert.Equal(t, &color, tag.Color)
assert.Equal(t, space.ID, tag.SpaceID)
})
}
func TestTagService_CreateTag_EmptyName(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
tagRepo := repository.NewTagRepository(dbi.DB)
svc := NewTagService(tagRepo)
user := testutil.CreateTestUser(t, dbi.DB, "tag-svc-empty@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Tag Svc Empty Space")
tag, err := svc.CreateTag(space.ID, "", nil)
assert.Error(t, err)
assert.Nil(t, tag)
})
}
func TestTagService_GetTagsForSpace(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
tagRepo := repository.NewTagRepository(dbi.DB)
svc := NewTagService(tagRepo)
user := testutil.CreateTestUser(t, dbi.DB, "tag-svc-list@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Tag Svc List Space")
testutil.CreateTestTag(t, dbi.DB, space.ID, "Alpha", nil)
testutil.CreateTestTag(t, dbi.DB, space.ID, "Beta", nil)
tags, err := svc.GetTagsForSpace(space.ID)
require.NoError(t, err)
require.Len(t, tags, 2)
})
}
func TestTagService_UpdateTag(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
tagRepo := repository.NewTagRepository(dbi.DB)
svc := NewTagService(tagRepo)
user := testutil.CreateTestUser(t, dbi.DB, "tag-svc-update@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Tag Svc Update Space")
tag := testutil.CreateTestTag(t, dbi.DB, space.ID, "Old Name", nil)
newColor := "#00ff00"
updated, err := svc.UpdateTag(tag.ID, "New Name", &newColor)
require.NoError(t, err)
assert.Equal(t, "new name", updated.Name)
assert.Equal(t, &newColor, updated.Color)
})
}
func TestTagService_DeleteTag(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
tagRepo := repository.NewTagRepository(dbi.DB)
svc := NewTagService(tagRepo)
user := testutil.CreateTestUser(t, dbi.DB, "tag-svc-delete@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Tag Svc Delete Space")
tag := testutil.CreateTestTag(t, dbi.DB, space.ID, "Doomed Tag", nil)
err := svc.DeleteTag(tag.ID)
require.NoError(t, err)
tags, err := svc.GetTagsForSpace(space.ID)
require.NoError(t, err)
assert.Empty(t, tags)
})
}
func TestNormalizeTagName(t *testing.T) {
result := NormalizeTagName(" Hello World ")
assert.Equal(t, "hello world", result)
}