first commit

This commit is contained in:
2026-04-29 02:35:00 +00:00
commit 2cb17df4c5
90 changed files with 7321 additions and 0 deletions

104
internal/invoice/lnbits.go Normal file
View File

@@ -0,0 +1,104 @@
package invoice
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
type LNbitsClient struct {
baseURL string
invoiceKey string
hc *http.Client
}
func NewLNbits(baseURL, invoiceKey string) *LNbitsClient {
return &LNbitsClient{
baseURL: strings.TrimRight(baseURL, "/"),
invoiceKey: invoiceKey,
hc: &http.Client{Timeout: 15 * time.Second},
}
}
type createReq struct {
Out bool `json:"out"`
Amount int64 `json:"amount"`
Memo string `json:"memo"`
Expiry int `json:"expiry,omitempty"`
}
type createResp struct {
PaymentHash string `json:"payment_hash"`
PaymentRequest string `json:"payment_request"`
BOLT11 string `json:"bolt11"`
}
type statusResp struct {
Paid bool `json:"paid"`
Pending bool `json:"pending"`
Details *struct {
Pending bool `json:"pending"`
Status string `json:"status"`
} `json:"details"`
}
func (c *LNbitsClient) Create(ctx context.Context, amountSats int64, memo string, expirySecs int) (string, string, error) {
body, err := json.Marshal(createReq{Out: false, Amount: amountSats, Memo: memo, Expiry: expirySecs})
if err != nil {
return "", "", err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/api/v1/payments", bytes.NewReader(body))
if err != nil {
return "", "", err
}
req.Header.Set("X-Api-Key", c.invoiceKey)
req.Header.Set("Content-Type", "application/json")
resp, err := c.hc.Do(req)
if err != nil {
return "", "", fmt.Errorf("%w: %v", ErrLNbits, err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
if resp.StatusCode/100 != 2 {
return "", "", fmt.Errorf("%w: %s", ErrLNbits, string(b))
}
var cr createResp
if err := json.Unmarshal(b, &cr); err != nil {
return "", "", fmt.Errorf("%w: decode: %v", ErrLNbits, err)
}
pr := cr.PaymentRequest
if pr == "" {
pr = cr.BOLT11
}
return cr.PaymentHash, pr, nil
}
func (c *LNbitsClient) Status(ctx context.Context, hash string) (bool, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/api/v1/payments/"+hash, nil)
if err != nil {
return false, err
}
req.Header.Set("X-Api-Key", c.invoiceKey)
resp, err := c.hc.Do(req)
if err != nil {
return false, fmt.Errorf("%w: %v", ErrLNbits, err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return false, nil
}
if resp.StatusCode/100 != 2 {
b, _ := io.ReadAll(resp.Body)
return false, fmt.Errorf("%w: %s", ErrLNbits, string(b))
}
var sr statusResp
if err := json.NewDecoder(resp.Body).Decode(&sr); err != nil {
return false, err
}
return sr.Paid, nil
}

52
internal/invoice/model.go Normal file
View File

@@ -0,0 +1,52 @@
package invoice
import (
"errors"
"time"
"github.com/noderunners/nip05api/internal/user"
)
type PendingInvoice struct {
PaymentHash string
PaymentRequest string
Username string
Pubkey string
SubscriptionType user.SubscriptionType
Years int
AmountSats int64
ExpiresAt time.Time
Paid bool
IsRenewal bool
CreatedAt time.Time
// TargetExpiresAt captures the user's new expiry value at first
// confirmation. Persisted so retries after a crash apply the same
// absolute value and stay idempotent. nil for lifetime.
TargetExpiresAt *time.Time
// TargetSet is true when target_expires_at has been written (even if
// the value is NULL for lifetime). Distinguishes "unset" from "lifetime".
TargetSet bool
}
type Status string
const (
StatusPending Status = "pending"
StatusPaid Status = "paid"
StatusExpired Status = "expired"
)
var (
ErrInvoiceNotFound = errors.New("invoice not found")
ErrLNbits = errors.New("lnbits error")
)
func (p *PendingInvoice) Status() Status {
if p.Paid {
return StatusPaid
}
if time.Now().UTC().After(p.ExpiresAt) {
return StatusExpired
}
return StatusPending
}

163
internal/invoice/repo.go Normal file
View File

@@ -0,0 +1,163 @@
package invoice
import (
"context"
"database/sql"
"errors"
"time"
"github.com/noderunners/nip05api/internal/db"
"github.com/noderunners/nip05api/internal/user"
)
type Repo struct{ db *db.DB }
func NewRepo(d *db.DB) *Repo { return &Repo{db: d} }
const invCols = `payment_hash, payment_request, username, pubkey, subscription_type,
years, amount_sats, expires_at, paid, is_renewal, created_at, target_expires_at`
func scanInvoice(row interface{ Scan(...any) error }) (*PendingInvoice, error) {
var p PendingInvoice
var sub, expires, created string
var paid, renewal int
var target sql.NullString
if err := row.Scan(&p.PaymentHash, &p.PaymentRequest, &p.Username, &p.Pubkey,
&sub, &p.Years, &p.AmountSats, &expires, &paid, &renewal, &created, &target); err != nil {
return nil, err
}
p.SubscriptionType = user.SubscriptionType(sub)
if t, err := time.Parse(time.RFC3339, expires); err == nil {
p.ExpiresAt = t
}
if t, err := time.Parse(time.RFC3339, created); err == nil {
p.CreatedAt = t
} else if t, err := time.Parse("2006-01-02 15:04:05", created); err == nil {
p.CreatedAt = t
}
p.Paid = paid == 1
p.IsRenewal = renewal == 1
if target.Valid {
p.TargetSet = true
if target.String != "" {
if t, err := time.Parse(time.RFC3339, target.String); err == nil {
p.TargetExpiresAt = &t
}
}
}
return &p, nil
}
func (r *Repo) Insert(ctx context.Context, p *PendingInvoice) error {
var target any
if p.TargetSet {
if p.TargetExpiresAt != nil {
target = p.TargetExpiresAt.UTC().Format(time.RFC3339)
} else {
target = ""
}
}
_, err := r.db.ExecContext(ctx, `INSERT INTO pending_invoices
(payment_hash, payment_request, username, pubkey, subscription_type,
years, amount_sats, expires_at, paid, is_renewal, target_expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
p.PaymentHash, p.PaymentRequest, p.Username, p.Pubkey,
string(p.SubscriptionType), p.Years, p.AmountSats,
p.ExpiresAt.UTC().Format(time.RFC3339),
boolToInt(p.Paid), boolToInt(p.IsRenewal), target)
return err
}
func (r *Repo) Get(ctx context.Context, hash string) (*PendingInvoice, error) {
row := r.db.QueryRowContext(ctx, `SELECT `+invCols+` FROM pending_invoices WHERE payment_hash = ?`, hash)
p, err := scanInvoice(row)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrInvoiceNotFound
}
return p, err
}
func (r *Repo) MarkPaid(ctx context.Context, hash string) error {
_, err := r.db.ExecContext(ctx, `UPDATE pending_invoices SET paid = 1 WHERE payment_hash = ?`, hash)
return err
}
// SetTargetIfUnset writes target_expires_at only when currently NULL.
// Returns true if this call won the race. Lifetime is encoded as empty string,
// allowing the caller to distinguish "not yet set" (NULL) from "set to nil".
func (r *Repo) SetTargetIfUnset(ctx context.Context, hash string, target *time.Time) (bool, error) {
stored := ""
if target != nil {
stored = target.UTC().Format(time.RFC3339)
}
res, err := r.db.ExecContext(ctx,
`UPDATE pending_invoices SET target_expires_at = ? WHERE payment_hash = ? AND target_expires_at IS NULL`,
stored, hash)
if err != nil {
return false, err
}
n, _ := res.RowsAffected()
return n == 1, nil
}
// ClaimPaid atomically transitions paid 0 → 1. Returns true if the caller
// performed the transition (i.e. it was unpaid before this call).
func (r *Repo) ClaimPaid(ctx context.Context, hash string) (bool, error) {
res, err := r.db.ExecContext(ctx,
`UPDATE pending_invoices SET paid = 1 WHERE payment_hash = ? AND paid = 0`, hash)
if err != nil {
return false, err
}
n, _ := res.RowsAffected()
return n == 1, nil
}
func (r *Repo) ListUnpaid(ctx context.Context) ([]*PendingInvoice, error) {
rows, err := r.db.QueryContext(ctx, `SELECT `+invCols+` FROM pending_invoices
WHERE paid = 0 AND expires_at > ?`,
time.Now().UTC().Format(time.RFC3339))
if err != nil {
return nil, err
}
defer rows.Close()
out := []*PendingInvoice{}
for rows.Next() {
p, err := scanInvoice(rows)
if err != nil {
return nil, err
}
out = append(out, p)
}
return out, rows.Err()
}
// HasUnpaidForUsername returns true if there is an active unpaid invoice for the username.
func (r *Repo) HasUnpaidForUsername(ctx context.Context, username string) (bool, error) {
var count int
err := r.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM pending_invoices
WHERE username = ? COLLATE NOCASE AND paid = 0 AND expires_at > ?`,
username, time.Now().UTC().Format(time.RFC3339)).Scan(&count)
return count > 0, err
}
// HasUnpaidForPubkey returns true if there is an active unpaid invoice for the pubkey.
func (r *Repo) HasUnpaidForPubkey(ctx context.Context, pubkey string) (bool, error) {
var count int
err := r.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM pending_invoices
WHERE pubkey = ? AND paid = 0 AND expires_at > ?`,
pubkey, time.Now().UTC().Format(time.RFC3339)).Scan(&count)
return count > 0, err
}
func (r *Repo) PurgeOldUnpaid(ctx context.Context) error {
cutoff := time.Now().UTC().Add(-1 * time.Hour).Format(time.RFC3339)
_, err := r.db.ExecContext(ctx, `DELETE FROM pending_invoices WHERE paid = 0 AND expires_at < ?`, cutoff)
return err
}
func boolToInt(b bool) int {
if b {
return 1
}
return 0
}

186
internal/invoice/service.go Normal file
View File

@@ -0,0 +1,186 @@
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")
}

View File

@@ -0,0 +1,227 @@
package invoice_test
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
"github.com/noderunners/nip05api/internal/db"
"github.com/noderunners/nip05api/internal/invoice"
"github.com/noderunners/nip05api/internal/user"
)
const testHex = "0e8c41ebcd55a8d8db2e0a8c3a4b9c5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c"
func newServiceFixture(t *testing.T) (*invoice.Service, *user.Service, *httptest.Server) {
t.Helper()
d, err := db.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatal(err)
}
if err := d.Migrate(context.Background()); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = d.Close() })
ln := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]string{
"payment_hash": "hash-" + r.URL.Path,
"payment_request": "lnbcfake",
})
}))
t.Cleanup(ln.Close)
users := user.NewService(user.NewRepo(d), []string{"admin", "root"})
client := invoice.NewLNbits(ln.URL, "key")
pricing := invoice.Pricing{YearlySats: 1000, LifetimeSats: 5000, ExpiryMins: 30}
svc := invoice.NewService(invoice.NewRepo(d), users, client, pricing, "test.local")
return svc, users, ln
}
func TestCreate_PubkeyOnlyDefaultsLifetime(t *testing.T) {
svc, _, _ := newServiceFixture(t)
p, err := svc.Create(context.Background(), invoice.CreateRequest{
Pubkey: testHex,
SubscriptionType: user.SubLifetime,
})
if err != nil {
t.Fatalf("create: %v", err)
}
if p.SubscriptionType != user.SubLifetime {
t.Errorf("expected lifetime, got %q", p.SubscriptionType)
}
if p.AmountSats != 5000 {
t.Errorf("expected lifetime price, got %d", p.AmountSats)
}
if !strings.HasPrefix(p.Username, "u_") {
t.Errorf("expected provisional username, got %q", p.Username)
}
if err := user.ValidateUsername(p.Username, nil); err != nil {
t.Errorf("provisional name should be valid: %v", err)
}
}
func TestCreate_YearlyDefaultsOneYear(t *testing.T) {
svc, _, _ := newServiceFixture(t)
p, err := svc.Create(context.Background(), invoice.CreateRequest{
Pubkey: testHex,
Username: "alice",
SubscriptionType: user.SubYearly,
Years: 1,
})
if err != nil {
t.Fatalf("create: %v", err)
}
if p.Years != 1 || p.AmountSats != 1000 {
t.Errorf("unexpected pending: years=%d amount=%d", p.Years, p.AmountSats)
}
if p.Username != "alice" {
t.Errorf("expected explicit username, got %q", p.Username)
}
}
func TestCreate_RenewalReusesUsername(t *testing.T) {
svc, users, _ := newServiceFixture(t)
if _, err := users.CreateOrActivate(context.Background(), testHex, "alice", user.SubYearly, 1, false); err != nil {
t.Fatal(err)
}
p, err := svc.Create(context.Background(), invoice.CreateRequest{
Pubkey: testHex,
SubscriptionType: user.SubLifetime,
})
if err != nil {
t.Fatalf("create: %v", err)
}
if !p.IsRenewal {
t.Error("expected renewal flag")
}
if p.Username != "alice" {
t.Errorf("expected stored username, got %q", p.Username)
}
}
func TestCreate_BlocksActiveLifetimeUser(t *testing.T) {
svc, users, _ := newServiceFixture(t)
if _, err := users.CreateOrActivate(context.Background(), testHex, "alice", user.SubLifetime, 0, false); err != nil {
t.Fatal(err)
}
_, err := svc.Create(context.Background(), invoice.CreateRequest{
Pubkey: testHex,
SubscriptionType: user.SubYearly,
Years: 1,
})
if !errors.Is(err, invoice.ErrLifetimeAccess) {
t.Fatalf("expected ErrLifetimeAccess, got %v", err)
}
}
func TestCreate_RejectsDuplicatePending(t *testing.T) {
svc, _, _ := newServiceFixture(t)
if _, err := svc.Create(context.Background(), invoice.CreateRequest{
Pubkey: testHex,
Username: "alice",
SubscriptionType: user.SubYearly,
Years: 1,
}); err != nil {
t.Fatalf("first create: %v", err)
}
_, err := svc.Create(context.Background(), invoice.CreateRequest{
Pubkey: testHex,
Username: "alice",
SubscriptionType: user.SubYearly,
Years: 1,
})
if !errors.Is(err, invoice.ErrPendingInvoiceExists) {
t.Fatalf("expected ErrPendingInvoiceExists, got %v", err)
}
}
func TestCreate_YearlyPersistsTargetExpiry(t *testing.T) {
svc, users, _ := newServiceFixture(t)
if _, err := users.CreateOrActivate(context.Background(), testHex, "alice", user.SubYearly, 1, false); err != nil {
t.Fatal(err)
}
existing, err := users.Repo().GetByPubkey(context.Background(), testHex)
if err != nil {
t.Fatal(err)
}
before := time.Now().UTC()
p, err := svc.Create(context.Background(), invoice.CreateRequest{
Pubkey: testHex,
SubscriptionType: user.SubYearly,
Years: 1,
})
if err != nil {
t.Fatalf("create: %v", err)
}
stored, err := svc.Repo().Get(context.Background(), p.PaymentHash)
if err != nil {
t.Fatal(err)
}
if !stored.TargetSet || stored.TargetExpiresAt == nil {
t.Fatalf("expected target persisted, got set=%v at=%v", stored.TargetSet, stored.TargetExpiresAt)
}
want := existing.ExpiresAt.AddDate(1, 0, 0)
if !stored.TargetExpiresAt.Equal(want) {
t.Errorf("target stacking mismatch: got %v want %v", stored.TargetExpiresAt, want)
}
if stored.TargetExpiresAt.Before(before) {
t.Error("target should be in the future")
}
}
func TestCreate_YearlyExpiredUserStartsFromNow(t *testing.T) {
svc, users, _ := newServiceFixture(t)
if _, err := users.CreateOrActivate(context.Background(), testHex, "alice", user.SubYearly, 1, false); err != nil {
t.Fatal(err)
}
u, err := users.Repo().GetByPubkey(context.Background(), testHex)
if err != nil {
t.Fatal(err)
}
past := time.Now().UTC().AddDate(0, 0, -1)
u.ExpiresAt = &past
if err := users.Repo().Update(context.Background(), u); err != nil {
t.Fatal(err)
}
before := time.Now().UTC()
p, err := svc.Create(context.Background(), invoice.CreateRequest{
Pubkey: testHex,
SubscriptionType: user.SubYearly,
Years: 1,
})
if err != nil {
t.Fatalf("create: %v", err)
}
stored, err := svc.Repo().Get(context.Background(), p.PaymentHash)
if err != nil {
t.Fatal(err)
}
if stored.TargetExpiresAt == nil {
t.Fatal("expected target")
}
want := before.AddDate(1, 0, 0)
delta := stored.TargetExpiresAt.Sub(want)
if delta < -2*time.Second || delta > 2*time.Second {
t.Errorf("expected ~now+1y (%v), got %v", want, stored.TargetExpiresAt)
}
}