Files
Nip-05-api/internal/invoice/service.go
2026-04-29 02:35:00 +00:00

187 lines
4.8 KiB
Go

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
}
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")
ErrPendingInvoiceExists = errors.New("pending unpaid invoice already exists")
)
// 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
}
hasPendingPubkey, err := s.repo.HasUnpaidForPubkey(ctx, req.Pubkey)
if err != nil {
return nil, err
}
if hasPendingPubkey {
return nil, ErrPendingInvoiceExists
}
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)
if req.SubscriptionType == user.SubLifetime {
amount = s.pricing.LifetimeSats
}
memo := fmt.Sprintf("%s@%s", username, s.domain)
if isRenewal {
memo = "renewal: " + memo
}
expirySecs := s.pricing.ExpiryMins * 60
hash, request, err := s.lnbits.Create(ctx, amount, memo, expirySecs)
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")
}