chore: refactor
All checks were successful
Deploy / build-and-deploy (push) Successful in 3m45s

This commit is contained in:
juancwu 2026-03-14 16:27:45 +00:00
commit 45fcecdc04
29 changed files with 2865 additions and 3867 deletions

View file

@ -132,18 +132,7 @@ func (s *ExpenseService) GetExpensesWithTagsForSpacePaginated(spaceID string, pa
return nil, 0, err
}
totalPages := (total + ExpensesPerPage - 1) / ExpensesPerPage
if totalPages < 1 {
totalPages = 1
}
if page < 1 {
page = 1
}
if page > totalPages {
page = totalPages
}
offset := (page - 1) * ExpensesPerPage
page, totalPages, offset := Paginate(page, total, ExpensesPerPage)
expenses, err := s.expenseRepo.GetBySpaceIDPaginated(spaceID, ExpensesPerPage, offset)
if err != nil {
return nil, 0, err
@ -175,18 +164,7 @@ func (s *ExpenseService) GetExpensesWithTagsAndMethodsForSpacePaginated(spaceID
return nil, 0, err
}
totalPages := (total + ExpensesPerPage - 1) / ExpensesPerPage
if totalPages < 1 {
totalPages = 1
}
if page < 1 {
page = 1
}
if page > totalPages {
page = totalPages
}
offset := (page - 1) * ExpensesPerPage
page, totalPages, offset := Paginate(page, total, ExpensesPerPage)
expenses, err := s.expenseRepo.GetBySpaceIDPaginated(spaceID, ExpensesPerPage, offset)
if err != nil {
return nil, 0, err

View file

@ -180,18 +180,7 @@ func (s *MoneyAccountService) GetTransfersForSpacePaginated(spaceID string, page
return nil, 0, err
}
totalPages := (total + TransfersPerPage - 1) / TransfersPerPage
if totalPages < 1 {
totalPages = 1
}
if page < 1 {
page = 1
}
if page > totalPages {
page = totalPages
}
offset := (page - 1) * TransfersPerPage
page, totalPages, offset := Paginate(page, total, TransfersPerPage)
transfers, err := s.accountRepo.GetTransfersBySpaceIDPaginated(spaceID, TransfersPerPage, offset)
if err != nil {
return nil, 0, err

View file

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

View file

@ -1,284 +0,0 @@
package service
import (
"fmt"
"log/slog"
"strings"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/repository"
"github.com/google/uuid"
)
type CreateRecurringDepositDTO struct {
SpaceID string
AccountID string
Amount int
Frequency model.Frequency
StartDate time.Time
EndDate *time.Time
Title string
CreatedBy string
}
type UpdateRecurringDepositDTO struct {
ID string
AccountID string
Amount int
Frequency model.Frequency
StartDate time.Time
EndDate *time.Time
Title string
}
type RecurringDepositService struct {
recurringRepo repository.RecurringDepositRepository
accountRepo repository.MoneyAccountRepository
expenseService *ExpenseService
profileRepo repository.ProfileRepository
spaceRepo repository.SpaceRepository
}
func NewRecurringDepositService(recurringRepo repository.RecurringDepositRepository, accountRepo repository.MoneyAccountRepository, expenseService *ExpenseService, profileRepo repository.ProfileRepository, spaceRepo repository.SpaceRepository) *RecurringDepositService {
return &RecurringDepositService{
recurringRepo: recurringRepo,
accountRepo: accountRepo,
expenseService: expenseService,
profileRepo: profileRepo,
spaceRepo: spaceRepo,
}
}
func (s *RecurringDepositService) CreateRecurringDeposit(dto CreateRecurringDepositDTO) (*model.RecurringDeposit, error) {
if dto.Amount <= 0 {
return nil, fmt.Errorf("amount must be positive")
}
now := time.Now()
rd := &model.RecurringDeposit{
ID: uuid.NewString(),
SpaceID: dto.SpaceID,
AccountID: dto.AccountID,
AmountCents: dto.Amount,
Frequency: dto.Frequency,
StartDate: dto.StartDate,
EndDate: dto.EndDate,
NextOccurrence: dto.StartDate,
IsActive: true,
Title: strings.TrimSpace(dto.Title),
CreatedBy: dto.CreatedBy,
CreatedAt: now,
UpdatedAt: now,
}
if err := s.recurringRepo.Create(rd); err != nil {
return nil, err
}
return rd, nil
}
func (s *RecurringDepositService) GetRecurringDeposit(id string) (*model.RecurringDeposit, error) {
return s.recurringRepo.GetByID(id)
}
func (s *RecurringDepositService) GetRecurringDepositsForSpace(spaceID string) ([]*model.RecurringDeposit, error) {
return s.recurringRepo.GetBySpaceID(spaceID)
}
func (s *RecurringDepositService) GetRecurringDepositsWithAccountsForSpace(spaceID string) ([]*model.RecurringDepositWithAccount, error) {
deposits, err := s.recurringRepo.GetBySpaceID(spaceID)
if err != nil {
return nil, err
}
accounts, err := s.accountRepo.GetBySpaceID(spaceID)
if err != nil {
return nil, err
}
accountNames := make(map[string]string, len(accounts))
for _, acct := range accounts {
accountNames[acct.ID] = acct.Name
}
result := make([]*model.RecurringDepositWithAccount, len(deposits))
for i, rd := range deposits {
result[i] = &model.RecurringDepositWithAccount{
RecurringDeposit: *rd,
AccountName: accountNames[rd.AccountID],
}
}
return result, nil
}
func (s *RecurringDepositService) UpdateRecurringDeposit(dto UpdateRecurringDepositDTO) (*model.RecurringDeposit, error) {
if dto.Amount <= 0 {
return nil, fmt.Errorf("amount must be positive")
}
existing, err := s.recurringRepo.GetByID(dto.ID)
if err != nil {
return nil, err
}
existing.AccountID = dto.AccountID
existing.AmountCents = dto.Amount
existing.Frequency = dto.Frequency
existing.StartDate = dto.StartDate
existing.EndDate = dto.EndDate
existing.Title = strings.TrimSpace(dto.Title)
existing.UpdatedAt = time.Now()
// Recalculate next occurrence if start date moved forward
if existing.NextOccurrence.Before(dto.StartDate) {
existing.NextOccurrence = dto.StartDate
}
if err := s.recurringRepo.Update(existing); err != nil {
return nil, err
}
return existing, nil
}
func (s *RecurringDepositService) DeleteRecurringDeposit(id string) error {
return s.recurringRepo.Delete(id)
}
func (s *RecurringDepositService) ToggleRecurringDeposit(id string) (*model.RecurringDeposit, error) {
rd, err := s.recurringRepo.GetByID(id)
if err != nil {
return nil, err
}
newActive := !rd.IsActive
if err := s.recurringRepo.SetActive(id, newActive); err != nil {
return nil, err
}
rd.IsActive = newActive
return rd, nil
}
func (s *RecurringDepositService) ProcessDueRecurrences(now time.Time) error {
dues, err := s.recurringRepo.GetDueRecurrences(now)
if err != nil {
return fmt.Errorf("failed to get due recurring deposits: %w", err)
}
tzCache := make(map[string]*time.Location)
for _, rd := range dues {
localNow := s.getLocalNow(rd.SpaceID, rd.CreatedBy, now, tzCache)
if err := s.processRecurrence(rd, localNow); err != nil {
slog.Error("failed to process recurring deposit", "id", rd.ID, "error", err)
}
}
return nil
}
func (s *RecurringDepositService) 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 deposits for space: %w", err)
}
tzCache := make(map[string]*time.Location)
for _, rd := range dues {
localNow := s.getLocalNow(rd.SpaceID, rd.CreatedBy, now, tzCache)
if err := s.processRecurrence(rd, localNow); err != nil {
slog.Error("failed to process recurring deposit", "id", rd.ID, "error", err)
}
}
return nil
}
// getLocalNow resolves the effective timezone for a recurring deposit.
// Resolution order: space timezone → user profile timezone → UTC.
func (s *RecurringDepositService) 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 (s *RecurringDepositService) getAvailableBalance(spaceID string) (int, error) {
totalBalance, err := s.expenseService.GetBalanceForSpace(spaceID)
if err != nil {
return 0, fmt.Errorf("failed to get space balance: %w", err)
}
totalAllocated, err := s.accountRepo.GetTotalAllocatedForSpace(spaceID)
if err != nil {
return 0, fmt.Errorf("failed to get total allocated: %w", err)
}
return totalBalance - totalAllocated, nil
}
func (s *RecurringDepositService) processRecurrence(rd *model.RecurringDeposit, now time.Time) error {
for !rd.NextOccurrence.After(now) {
// Check if end_date has been passed
if rd.EndDate != nil && rd.NextOccurrence.After(*rd.EndDate) {
return s.recurringRepo.Deactivate(rd.ID)
}
// Check available balance
availableBalance, err := s.getAvailableBalance(rd.SpaceID)
if err != nil {
return err
}
if availableBalance >= rd.AmountCents {
rdID := rd.ID
transfer := &model.AccountTransfer{
ID: uuid.NewString(),
AccountID: rd.AccountID,
AmountCents: rd.AmountCents,
Direction: model.TransferDirectionDeposit,
Note: rd.Title,
RecurringDepositID: &rdID,
CreatedBy: rd.CreatedBy,
CreatedAt: time.Now(),
}
if err := s.accountRepo.CreateTransfer(transfer); err != nil {
return fmt.Errorf("failed to create deposit transfer: %w", err)
}
} else {
slog.Warn("recurring deposit skipped: insufficient available balance",
"recurring_deposit_id", rd.ID,
"space_id", rd.SpaceID,
"needed", rd.AmountCents,
"available", availableBalance,
)
}
rd.NextOccurrence = AdvanceDate(rd.NextOccurrence, rd.Frequency)
}
// Check if the new next occurrence exceeds end date
if rd.EndDate != nil && rd.NextOccurrence.After(*rd.EndDate) {
if err := s.recurringRepo.Deactivate(rd.ID); err != nil {
return err
}
}
return s.recurringRepo.UpdateNextOccurrence(rd.ID, rd.NextOccurrence)
}

View file

@ -127,18 +127,7 @@ func (s *ShoppingListService) GetItemsForListPaginated(listID string, page int)
return nil, 0, err
}
totalPages := (total + ItemsPerCardPage - 1) / ItemsPerCardPage
if totalPages < 1 {
totalPages = 1
}
if page < 1 {
page = 1
}
if page > totalPages {
page = totalPages
}
offset := (page - 1) * ItemsPerCardPage
page, totalPages, offset := Paginate(page, total, ItemsPerCardPage)
items, err := s.itemRepo.GetByListIDPaginated(listID, ItemsPerCardPage, offset)
if err != nil {
return nil, 0, err