feat: set timezone space level
All checks were successful
Deploy / build-and-deploy (push) Successful in 2m25s

This commit is contained in:
juancwu 2026-03-03 14:48:11 +00:00
commit 08f6b034f5
12 changed files with 196 additions and 23 deletions

View file

@ -37,14 +37,16 @@ type RecurringDepositService struct {
accountRepo repository.MoneyAccountRepository
expenseService *ExpenseService
profileRepo repository.ProfileRepository
spaceRepo repository.SpaceRepository
}
func NewRecurringDepositService(recurringRepo repository.RecurringDepositRepository, accountRepo repository.MoneyAccountRepository, expenseService *ExpenseService, profileRepo repository.ProfileRepository) *RecurringDepositService {
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,
}
}
@ -165,8 +167,8 @@ func (s *RecurringDepositService) ProcessDueRecurrences(now time.Time) error {
tzCache := make(map[string]*time.Location)
for _, rd := range dues {
userNow := s.getUserNow(rd.CreatedBy, now, tzCache)
if err := s.processRecurrence(rd, userNow); err != nil {
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)
}
}
@ -181,16 +183,32 @@ func (s *RecurringDepositService) ProcessDueRecurrencesForSpace(spaceID string,
tzCache := make(map[string]*time.Location)
for _, rd := range dues {
userNow := s.getUserNow(rd.CreatedBy, now, tzCache)
if err := s.processRecurrence(rd, userNow); err != nil {
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) getUserNow(userID string, now time.Time, cache map[string]*time.Location) time.Time {
if loc, ok := cache[userID]; ok {
// 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)
}
@ -199,7 +217,7 @@ func (s *RecurringDepositService) getUserNow(userID string, now time.Time, cache
if err == nil && profile != nil {
loc = profile.Location()
}
cache[userID] = loc
cache[userKey] = loc
return now.In(loc)
}

View file

@ -39,13 +39,15 @@ 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) *RecurringExpenseService {
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,
}
}
@ -180,8 +182,8 @@ func (s *RecurringExpenseService) ProcessDueRecurrences(now time.Time) error {
tzCache := make(map[string]*time.Location)
for _, re := range dues {
userNow := s.getUserNow(re.CreatedBy, now, tzCache)
if err := s.processRecurrence(re, userNow); err != nil {
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)
}
}
@ -196,8 +198,8 @@ func (s *RecurringExpenseService) ProcessDueRecurrencesForSpace(spaceID string,
tzCache := make(map[string]*time.Location)
for _, re := range dues {
userNow := s.getUserNow(re.CreatedBy, now, tzCache)
if err := s.processRecurrence(re, userNow); err != nil {
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)
}
}
@ -253,10 +255,24 @@ 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 {
// 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)
}
@ -265,7 +281,7 @@ func (s *RecurringExpenseService) getUserNow(userID string, now time.Time, cache
if err == nil && profile != nil {
loc = profile.Location()
}
cache[userID] = loc
cache[userKey] = loc
return now.In(loc)
}

View file

@ -110,3 +110,11 @@ 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)
}