Files
Nip-05-api/internal/invoice/service.go
Michilis 43d78862e3 Add configurable LNbits invoice memos and pubkey metadata
Read INVOICE_MEMO_YEARLY and INVOICE_MEMO_LIFETIME from the environment
and pass the user pubkey in LNbits payment extra for invoice creation.
2026-05-06 19:52:07 +00:00

205 lines
5.4 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
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")
}