first commit
This commit is contained in:
104
internal/invoice/lnbits.go
Normal file
104
internal/invoice/lnbits.go
Normal 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
52
internal/invoice/model.go
Normal 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
163
internal/invoice/repo.go
Normal 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
186
internal/invoice/service.go
Normal 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")
|
||||
}
|
||||
227
internal/invoice/service_test.go
Normal file
227
internal/invoice/service_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user