feat: set timezone user level

This commit is contained in:
juancwu 2026-03-03 12:29:41 +00:00
commit 945069052f
11 changed files with 261 additions and 18 deletions

View file

@ -1,10 +1,15 @@
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
}
@ -18,3 +23,10 @@ func NewProfileService(profileRepository repository.ProfileRepository) *ProfileS
func (s *ProfileService) ByUserID(userID string) (*model.Profile, error) {
return s.profileRepository.ByUserID(userID)
}
func (s *ProfileService) UpdateTimezone(userID, timezone string) error {
if _, err := time.LoadLocation(timezone); err != nil {
return ErrInvalidTimezone
}
return s.profileRepository.UpdateTimezone(userID, timezone)
}

View file

@ -36,13 +36,15 @@ type RecurringDepositService struct {
recurringRepo repository.RecurringDepositRepository
accountRepo repository.MoneyAccountRepository
expenseService *ExpenseService
profileRepo repository.ProfileRepository
}
func NewRecurringDepositService(recurringRepo repository.RecurringDepositRepository, accountRepo repository.MoneyAccountRepository, expenseService *ExpenseService) *RecurringDepositService {
func NewRecurringDepositService(recurringRepo repository.RecurringDepositRepository, accountRepo repository.MoneyAccountRepository, expenseService *ExpenseService, profileRepo repository.ProfileRepository) *RecurringDepositService {
return &RecurringDepositService{
recurringRepo: recurringRepo,
accountRepo: accountRepo,
expenseService: expenseService,
profileRepo: profileRepo,
}
}
@ -161,8 +163,10 @@ func (s *RecurringDepositService) ProcessDueRecurrences(now time.Time) error {
return fmt.Errorf("failed to get due recurring deposits: %w", err)
}
tzCache := make(map[string]*time.Location)
for _, rd := range dues {
if err := s.processRecurrence(rd, now); err != nil {
userNow := s.getUserNow(rd.CreatedBy, now, tzCache)
if err := s.processRecurrence(rd, userNow); err != nil {
slog.Error("failed to process recurring deposit", "id", rd.ID, "error", err)
}
}
@ -175,14 +179,30 @@ func (s *RecurringDepositService) ProcessDueRecurrencesForSpace(spaceID string,
return fmt.Errorf("failed to get due recurring deposits for space: %w", err)
}
tzCache := make(map[string]*time.Location)
for _, rd := range dues {
if err := s.processRecurrence(rd, now); err != nil {
userNow := s.getUserNow(rd.CreatedBy, now, tzCache)
if err := s.processRecurrence(rd, userNow); err != nil {
slog.Error("failed to process recurring deposit", "id", rd.ID, "error", err)
}
}
return nil
}
func (s *RecurringDepositService) getUserNow(userID string, now time.Time, cache map[string]*time.Location) time.Time {
if loc, ok := cache[userID]; ok {
return now.In(loc)
}
loc := time.UTC
profile, err := s.profileRepo.ByUserID(userID)
if err == nil && profile != nil {
loc = profile.Location()
}
cache[userID] = loc
return now.In(loc)
}
func (s *RecurringDepositService) getAvailableBalance(spaceID string) (int, error) {
totalBalance, err := s.expenseService.GetBalanceForSpace(spaceID)
if err != nil {

View file

@ -38,12 +38,14 @@ type UpdateRecurringExpenseDTO struct {
type RecurringExpenseService struct {
recurringRepo repository.RecurringExpenseRepository
expenseRepo repository.ExpenseRepository
profileRepo repository.ProfileRepository
}
func NewRecurringExpenseService(recurringRepo repository.RecurringExpenseRepository, expenseRepo repository.ExpenseRepository) *RecurringExpenseService {
func NewRecurringExpenseService(recurringRepo repository.RecurringExpenseRepository, expenseRepo repository.ExpenseRepository, profileRepo repository.ProfileRepository) *RecurringExpenseService {
return &RecurringExpenseService{
recurringRepo: recurringRepo,
expenseRepo: expenseRepo,
profileRepo: profileRepo,
}
}
@ -176,8 +178,10 @@ func (s *RecurringExpenseService) ProcessDueRecurrences(now time.Time) error {
return fmt.Errorf("failed to get due recurrences: %w", err)
}
tzCache := make(map[string]*time.Location)
for _, re := range dues {
if err := s.processRecurrence(re, now); err != nil {
userNow := s.getUserNow(re.CreatedBy, now, tzCache)
if err := s.processRecurrence(re, userNow); err != nil {
slog.Error("failed to process recurring expense", "id", re.ID, "error", err)
}
}
@ -190,8 +194,10 @@ func (s *RecurringExpenseService) ProcessDueRecurrencesForSpace(spaceID string,
return fmt.Errorf("failed to get due recurrences for space: %w", err)
}
tzCache := make(map[string]*time.Location)
for _, re := range dues {
if err := s.processRecurrence(re, now); err != nil {
userNow := s.getUserNow(re.CreatedBy, now, tzCache)
if err := s.processRecurrence(re, userNow); err != nil {
slog.Error("failed to process recurring expense", "id", re.ID, "error", err)
}
}
@ -247,6 +253,22 @@ func (s *RecurringExpenseService) processRecurrence(re *model.RecurringExpense,
return s.recurringRepo.UpdateNextOccurrence(re.ID, re.NextOccurrence)
}
// getUserNow converts the server time to the user's local time based on their
// profile timezone setting. Falls back to UTC if no timezone is set.
func (s *RecurringExpenseService) getUserNow(userID string, now time.Time, cache map[string]*time.Location) time.Time {
if loc, ok := cache[userID]; ok {
return now.In(loc)
}
loc := time.UTC
profile, err := s.profileRepo.ByUserID(userID)
if err == nil && profile != nil {
loc = profile.Location()
}
cache[userID] = loc
return now.In(loc)
}
func AdvanceDate(date time.Time, freq model.Frequency) time.Time {
switch freq {
case model.FrequencyDaily: