package user import ( "context" "errors" "time" ) type Service struct { repo *Repo reserved []string } func NewService(repo *Repo, reserved []string) *Service { return &Service{repo: repo, reserved: reserved} } func (s *Service) Repo() *Repo { return s.repo } func (s *Service) Reserved() []string { return s.reserved } // IsAvailable returns true if no active/in-grace user has the username. func (s *Service) IsAvailable(ctx context.Context, username string) (bool, error) { if err := ValidateUsername(username, s.reserved); err != nil { return false, err } u, err := s.repo.GetByUsername(ctx, NormalizeUsername(username)) if errors.Is(err, ErrUserNotFound) { return true, nil } if err != nil { return false, err } _ = u return false, nil } // CreateOrActivate inserts a new active user. Caller is responsible for transactional // concerns (e.g. payments worker uses this within a tx). func (s *Service) CreateOrActivate(ctx context.Context, pubkey, username string, sub SubscriptionType, years int, manual bool) (*User, error) { username = NormalizeUsername(username) if err := ValidateUsername(username, s.reserved); err != nil { return nil, err } now := time.Now().UTC() expiresAt := computeExpiry(sub, years, time.Time{}, now) u := &User{ Pubkey: pubkey, Username: username, SubscriptionType: sub, ExpiresAt: expiresAt, IsActive: true, ManualUsername: manual, } if err := s.repo.Insert(ctx, u); err != nil { return nil, err } return u, nil } // Renew extends an existing user. Used by payments worker and admin extend. func (s *Service) Renew(ctx context.Context, u *User, sub SubscriptionType, years int) error { now := time.Now().UTC() var current time.Time if u.ExpiresAt != nil { current = *u.ExpiresAt } u.SubscriptionType = sub u.ExpiresAt = computeExpiry(sub, years, current, now) u.IsActive = true u.DeactivatedAt = nil u.ExpiringReminderSentAt = nil return s.repo.Update(ctx, u) } func (s *Service) SetUsername(ctx context.Context, pubkey, username string) (*User, error) { username = NormalizeUsername(username) if err := ValidateUsername(username, s.reserved); err != nil { return nil, err } u, err := s.repo.GetByPubkey(ctx, pubkey) if err != nil { return nil, err } if existing, err := s.repo.GetByUsername(ctx, username); err == nil && existing.Pubkey != pubkey { return nil, ErrUsernameTaken } u.Username = username u.ManualUsername = true if err := s.repo.Update(ctx, u); err != nil { return nil, err } return u, nil } func (s *Service) Delete(ctx context.Context, pubkey string) error { return s.repo.Delete(ctx, pubkey) } // computeExpiry returns *time.Time (nil for lifetime). func computeExpiry(sub SubscriptionType, years int, current time.Time, now time.Time) *time.Time { if sub == SubLifetime { return nil } var cur time.Time if !current.IsZero() { cur = current } return YearlyTargetExpiry(&cur, years, now) } // YearlyTargetExpiry computes the new expiry for a yearly subscription using // effective_start = max(now, current_expiry); new_expiry = effective_start + years. // currentExpiry may be nil or zero for first-time purchases. func YearlyTargetExpiry(currentExpiry *time.Time, years int, now time.Time) *time.Time { if years <= 0 { years = 1 } base := now if currentExpiry != nil && currentExpiry.After(base) { base = *currentExpiry } t := base.AddDate(years, 0, 0) return &t }