chore: massive reset
This commit is contained in:
parent
c7ee3da8f2
commit
df164ab0f4
96 changed files with 198 additions and 15405 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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},
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue