package invoice import ( "context" "errors" "fmt" "time" "github.com/noderunners/nip05api/internal/user" ) type CreateRequest struct { Username string Pubkey string SubscriptionType user.SubscriptionType Years int } type Pricing struct { YearlySats int64 LifetimeSats int64 ExpiryMins int MemoYearly string MemoLifetime string } type Service struct { repo *Repo users *user.Service lnbits *LNbitsClient pricing Pricing domain string } func NewService(repo *Repo, users *user.Service, ln *LNbitsClient, p Pricing, domain string) *Service { return &Service{repo: repo, users: users, lnbits: ln, pricing: p, domain: domain} } func (s *Service) Repo() *Repo { return s.repo } func (s *Service) Pricing() Pricing { return s.pricing } var ( ErrUsernameMismatch = errors.New("username does not match existing record") ErrUsernameTaken = errors.New("username taken") ErrInvalidYears = errors.New("invalid years") ErrLifetimeAccess = errors.New("user already has lifetime access") // Deprecated: Create no longer returns this; an existing unpaid invoice is returned instead. ErrPendingInvoiceExists = errors.New("pending unpaid invoice already exists") ) func pendingMatchesRequest(p *PendingInvoice, req CreateRequest) bool { if p.SubscriptionType != req.SubscriptionType { return false } if req.SubscriptionType == user.SubYearly { return p.Years == req.Years } return true } // Create computes amount, calls LNbits, persists pending invoice. Detects renewal. func (s *Service) Create(ctx context.Context, req CreateRequest) (*PendingInvoice, error) { if !req.SubscriptionType.Valid() { return nil, fmt.Errorf("invalid subscription_type") } if req.SubscriptionType == user.SubYearly { if req.Years <= 0 { req.Years = 1 } if req.Years > 10 { return nil, ErrInvalidYears } } else { req.Years = 0 } pendingExisting, err := s.repo.GetActiveUnpaidByPubkey(ctx, req.Pubkey) if err != nil { return nil, err } if pendingExisting != nil { if pendingMatchesRequest(pendingExisting, req) { // Idempotent resume: same Bolt11 until paid or LN invoice expiry. return pendingExisting, nil } // Replace pending Bolt11 with one for the newly requested plan. if err := s.repo.DeleteActiveUnpaidForPubkey(ctx, req.Pubkey); err != nil { return nil, err } } username := user.NormalizeUsername(req.Username) existing, err := s.users.Repo().GetByPubkey(ctx, req.Pubkey) isRenewal := false switch { case err == nil: if existing.IsLifetime() && existing.IsActive { return nil, ErrLifetimeAccess } isRenewal = true if username == "" { username = existing.Username } else if username != existing.Username { return nil, ErrUsernameMismatch } case errors.Is(err, user.ErrUserNotFound): if username == "" { generated, gerr := s.allocateProvisionalUsername(ctx, req.Pubkey) if gerr != nil { return nil, gerr } username = generated } else { if err := user.ValidateUsername(username, s.users.Reserved()); err != nil { return nil, err } taken, err := s.users.Repo().GetByUsername(ctx, username) if err == nil && taken.Pubkey != req.Pubkey { return nil, ErrUsernameTaken } hasPending, err := s.repo.HasUnpaidForUsername(ctx, username) if err != nil { return nil, err } if hasPending { return nil, ErrUsernameTaken } } default: return nil, err } amount := s.pricing.YearlySats * int64(req.Years) memo := s.pricing.MemoYearly if req.SubscriptionType == user.SubLifetime { amount = s.pricing.LifetimeSats memo = s.pricing.MemoLifetime } expirySecs := s.pricing.ExpiryMins * 60 extra := map[string]string{"pubkey": req.Pubkey} hash, request, err := s.lnbits.Create(ctx, amount, memo, expirySecs, extra) if err != nil { return nil, err } now := time.Now().UTC() p := &PendingInvoice{ PaymentHash: hash, PaymentRequest: request, Username: username, Pubkey: req.Pubkey, SubscriptionType: req.SubscriptionType, Years: req.Years, AmountSats: amount, ExpiresAt: now.Add(time.Duration(s.pricing.ExpiryMins) * time.Minute), Paid: false, IsRenewal: isRenewal, CreatedAt: now, } if req.SubscriptionType == user.SubYearly { var current *time.Time if existing != nil { current = existing.ExpiresAt } p.TargetExpiresAt = user.YearlyTargetExpiry(current, req.Years, now) p.TargetSet = true } if err := s.repo.Insert(ctx, p); err != nil { return nil, err } return p, nil } // allocateProvisionalUsername finds a unique placeholder handle for a pubkey // that has no chosen username yet. The base form derives from the pubkey, with // numeric suffixes added on the rare collision. func (s *Service) allocateProvisionalUsername(ctx context.Context, pubkey string) (string, error) { base := user.ProvisionalUsername(pubkey) for attempt := 0; attempt < 20; attempt++ { candidate := base if attempt > 0 { candidate = fmt.Sprintf("%s_%d", base, attempt+1) } if err := user.ValidateUsername(candidate, s.users.Reserved()); err != nil { continue } taken, err := s.users.Repo().GetByUsername(ctx, candidate) if err == nil && taken.Pubkey != pubkey { continue } hasPending, err := s.repo.HasUnpaidForUsername(ctx, candidate) if err != nil { return "", err } if hasPending { continue } return candidate, nil } return "", fmt.Errorf("could not allocate provisional username") }