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

View File

@@ -0,0 +1,69 @@
package payments
import (
"context"
"log/slog"
"strconv"
"time"
"github.com/noderunners/nip05api/internal/audit"
"github.com/noderunners/nip05api/internal/dm"
"github.com/noderunners/nip05api/internal/invoice"
"github.com/noderunners/nip05api/internal/nostr"
"github.com/noderunners/nip05api/internal/user"
"github.com/noderunners/nip05api/internal/webhook"
)
func (w *Worker) dispatchEvents(ctx context.Context, u *user.User, p *invoice.PendingInvoice, ev dm.EventType) {
vars := buildVars(u, w.domain, w.frontend)
if err := w.dms.Send(ctx, ev, u.Pubkey, vars); err != nil {
slog.Error("dm enqueue", "err", err)
}
data := map[string]any{
"pubkey": u.Pubkey,
"npub": nostr.HexToNpub(u.Pubkey),
"username": u.Username,
"subscription_type": string(u.SubscriptionType),
"amount_sats": p.AmountSats,
"payment_hash": p.PaymentHash,
"is_renewal": p.IsRenewal,
}
if u.ExpiresAt != nil {
data["expires_at"] = u.ExpiresAt.UTC().Format(time.RFC3339)
}
if err := w.hooks.Enqueue(ctx, webhook.EventUserPaid, data); err != nil {
slog.Error("webhook enqueue", "err", err)
}
w.audit.Log(ctx, audit.ActionPaymentConfirmed, audit.ActorSystem, u.Pubkey, map[string]any{
"payment_hash": p.PaymentHash,
"amount_sats": p.AmountSats,
"is_renewal": p.IsRenewal,
"event": string(ev),
})
slog.Info("payment confirmed",
"pubkey", u.Pubkey, "username", u.Username,
"amount_sats", p.AmountSats, "renewal", p.IsRenewal)
}
func buildVars(u *user.User, domain, frontend string) map[string]string {
expires := "lifetime"
days := ""
if u.ExpiresAt != nil {
expires = u.ExpiresAt.Format("2006-01-02")
d := int(time.Until(*u.ExpiresAt).Hours() / 24)
if d < 0 {
d = 0
}
days = strconv.Itoa(d)
}
return map[string]string{
"username": u.Username,
"npub": nostr.HexToNpub(u.Pubkey),
"pubkey": u.Pubkey,
"domain": domain,
"expires_at": expires,
"days_remaining": days,
"frontend_url": frontend,
"subscription_type": string(u.SubscriptionType),
}
}

173
internal/payments/worker.go Normal file
View File

@@ -0,0 +1,173 @@
package payments
import (
"context"
"errors"
"log/slog"
"time"
"github.com/noderunners/nip05api/internal/audit"
"github.com/noderunners/nip05api/internal/dm"
"github.com/noderunners/nip05api/internal/invoice"
"github.com/noderunners/nip05api/internal/user"
"github.com/noderunners/nip05api/internal/webhook"
)
type Worker struct {
invoices *invoice.Service
users *user.Service
lnbits *invoice.LNbitsClient
dms *dm.Service
hooks *webhook.Service
audit *audit.Logger
domain string
frontend string
interval time.Duration
enabled bool
}
func NewWorker(inv *invoice.Service, u *user.Service, ln *invoice.LNbitsClient, dms *dm.Service, hooks *webhook.Service, aud *audit.Logger, domain, frontend string, enabled bool) *Worker {
return &Worker{
invoices: inv,
users: u,
lnbits: ln,
dms: dms,
hooks: hooks,
audit: aud,
domain: domain,
frontend: frontend,
interval: 5 * time.Second,
enabled: enabled,
}
}
func (w *Worker) Run(ctx context.Context) {
if !w.enabled {
<-ctx.Done()
return
}
t := time.NewTicker(w.interval)
defer t.Stop()
cleanup := time.NewTicker(15 * time.Minute)
defer cleanup.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
w.tick(ctx)
case <-cleanup.C:
_ = w.invoices.Repo().PurgeOldUnpaid(ctx)
}
}
}
func (w *Worker) tick(ctx context.Context) {
pending, err := w.invoices.Repo().ListUnpaid(ctx)
if err != nil {
slog.Error("payments list", "err", err)
return
}
for _, p := range pending {
paid, err := w.lnbits.Status(ctx, p.PaymentHash)
if err != nil {
slog.Warn("lnbits status", "hash", p.PaymentHash, "err", err)
continue
}
if !paid {
continue
}
if err := w.confirm(ctx, p); err != nil {
slog.Error("confirm payment", "hash", p.PaymentHash, "err", err)
}
}
}
// confirm completes a paid invoice idempotently. Crash recovery is safe at any
// point: we capture the target expiry once, then apply absolute updates.
func (w *Worker) confirm(ctx context.Context, p *invoice.PendingInvoice) error {
existing, getErr := w.users.Repo().GetByPubkey(ctx, p.Pubkey)
if getErr != nil && !errors.Is(getErr, user.ErrUserNotFound) {
return getErr
}
target, err := w.resolveTarget(ctx, p, existing)
if err != nil {
return err
}
wasNew := errors.Is(getErr, user.ErrUserNotFound)
if wasNew {
u := &user.User{
Pubkey: p.Pubkey,
Username: p.Username,
SubscriptionType: p.SubscriptionType,
ExpiresAt: target,
IsActive: true,
}
if err := w.users.Repo().Insert(ctx, u); err != nil {
// Likely UNIQUE constraint from a concurrent recovery attempt;
// re-fetch and treat as existing.
existing2, err2 := w.users.Repo().GetByPubkey(ctx, p.Pubkey)
if err2 != nil {
return err
}
existing = existing2
wasNew = false
}
}
if !wasNew {
if err := w.users.Repo().SetActiveExpiry(ctx, p.Pubkey, p.SubscriptionType, target); err != nil {
return err
}
}
claimed, err := w.invoices.Repo().ClaimPaid(ctx, p.PaymentHash)
if err != nil {
return err
}
if !claimed {
return nil // another tick already dispatched events
}
final, err := w.users.Repo().GetByPubkey(ctx, p.Pubkey)
if err != nil {
return err
}
dmEvent := dm.EventWelcome
if !wasNew && p.IsRenewal {
dmEvent = dm.EventExtended
}
w.dispatchEvents(ctx, final, p, dmEvent)
return nil
}
// resolveTarget returns the canonical expiry to apply to the user. Persisted
// on first call so retries see the same value.
func (w *Worker) resolveTarget(ctx context.Context, p *invoice.PendingInvoice, existing *user.User) (*time.Time, error) {
if p.TargetSet {
return p.TargetExpiresAt, nil
}
target := computeTarget(p, existing, time.Now().UTC())
if _, err := w.invoices.Repo().SetTargetIfUnset(ctx, p.PaymentHash, target); err != nil {
return nil, err
}
fresh, err := w.invoices.Repo().Get(ctx, p.PaymentHash)
if err != nil {
return nil, err
}
return fresh.TargetExpiresAt, nil
}
func computeTarget(p *invoice.PendingInvoice, existing *user.User, now time.Time) *time.Time {
if p.SubscriptionType == user.SubLifetime {
return nil
}
var current *time.Time
if existing != nil {
current = existing.ExpiresAt
}
return user.YearlyTargetExpiry(current, p.Years, now)
}

View File

@@ -0,0 +1,180 @@
package payments
import (
"context"
"path/filepath"
"testing"
"time"
"github.com/noderunners/nip05api/internal/db"
"github.com/noderunners/nip05api/internal/invoice"
"github.com/noderunners/nip05api/internal/user"
)
const testHex = "0e8c41ebcd55a8d8db2e0a8c3a4b9c5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c"
func newTestDB(t *testing.T) *db.DB {
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() })
return d
}
func TestComputeTarget_NewYearly(t *testing.T) {
now := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
p := &invoice.PendingInvoice{SubscriptionType: user.SubYearly, Years: 1}
got := computeTarget(p, nil, now)
if got == nil || !got.Equal(now.AddDate(1, 0, 0)) {
t.Errorf("got %v want %v", got, now.AddDate(1, 0, 0))
}
}
func TestComputeTarget_RenewActive(t *testing.T) {
now := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
current := now.AddDate(0, 6, 0)
p := &invoice.PendingInvoice{SubscriptionType: user.SubYearly, Years: 1}
existing := &user.User{ExpiresAt: &current}
got := computeTarget(p, existing, now)
want := current.AddDate(1, 0, 0)
if !got.Equal(want) {
t.Errorf("got %v want %v", got, want)
}
}
func TestComputeTarget_RenewExpired(t *testing.T) {
now := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
past := now.AddDate(0, -1, 0)
p := &invoice.PendingInvoice{SubscriptionType: user.SubYearly, Years: 1}
existing := &user.User{ExpiresAt: &past}
got := computeTarget(p, existing, now)
want := now.AddDate(1, 0, 0)
if !got.Equal(want) {
t.Errorf("got %v want %v", got, want)
}
}
func TestComputeTarget_Lifetime(t *testing.T) {
now := time.Now()
p := &invoice.PendingInvoice{SubscriptionType: user.SubLifetime}
if got := computeTarget(p, nil, now); got != nil {
t.Errorf("expected nil, got %v", got)
}
}
// Idempotent confirm: applying the same target twice produces identical state.
func TestSetActiveExpiry_Idempotent(t *testing.T) {
d := newTestDB(t)
repo := user.NewRepo(d)
expires := time.Date(2027, 6, 1, 0, 0, 0, 0, time.UTC)
u := &user.User{
Pubkey: testHex,
Username: "alice",
SubscriptionType: user.SubYearly,
ExpiresAt: &expires,
IsActive: true,
}
if err := repo.Insert(context.Background(), u); err != nil {
t.Fatal(err)
}
target := time.Date(2028, 6, 1, 0, 0, 0, 0, time.UTC)
if err := repo.SetActiveExpiry(context.Background(), testHex, user.SubYearly, &target); err != nil {
t.Fatal(err)
}
got, err := repo.GetByPubkey(context.Background(), testHex)
if err != nil {
t.Fatal(err)
}
if !got.ExpiresAt.Equal(target) {
t.Errorf("first apply: got %v want %v", got.ExpiresAt, target)
}
// Re-apply same target — must not advance further.
if err := repo.SetActiveExpiry(context.Background(), testHex, user.SubYearly, &target); err != nil {
t.Fatal(err)
}
got, _ = repo.GetByPubkey(context.Background(), testHex)
if !got.ExpiresAt.Equal(target) {
t.Errorf("re-apply changed value: got %v want %v", got.ExpiresAt, target)
}
}
func TestSetTargetIfUnset_OnlyOnce(t *testing.T) {
d := newTestDB(t)
repo := invoice.NewRepo(d)
target1 := time.Date(2027, 6, 1, 0, 0, 0, 0, time.UTC)
p := &invoice.PendingInvoice{
PaymentHash: "hash1",
PaymentRequest: "lnbc1...",
Username: "alice",
Pubkey: testHex,
SubscriptionType: user.SubYearly,
Years: 1,
AmountSats: 1000,
ExpiresAt: time.Now().Add(30 * time.Minute),
}
if err := repo.Insert(context.Background(), p); err != nil {
t.Fatal(err)
}
won, err := repo.SetTargetIfUnset(context.Background(), "hash1", &target1)
if err != nil || !won {
t.Fatalf("first set: won=%v err=%v", won, err)
}
target2 := time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC)
won, err = repo.SetTargetIfUnset(context.Background(), "hash1", &target2)
if err != nil {
t.Fatal(err)
}
if won {
t.Error("second set should be a no-op")
}
fresh, err := repo.Get(context.Background(), "hash1")
if err != nil {
t.Fatal(err)
}
if !fresh.TargetSet || fresh.TargetExpiresAt == nil || !fresh.TargetExpiresAt.Equal(target1) {
t.Errorf("target should be the first value, got set=%v at=%v",
fresh.TargetSet, fresh.TargetExpiresAt)
}
}
func TestClaimPaid_Atomic(t *testing.T) {
d := newTestDB(t)
repo := invoice.NewRepo(d)
p := &invoice.PendingInvoice{
PaymentHash: "hash2",
PaymentRequest: "lnbc...",
Username: "bob",
Pubkey: testHex,
SubscriptionType: user.SubYearly,
Years: 1,
AmountSats: 1000,
ExpiresAt: time.Now().Add(30 * time.Minute),
}
if err := repo.Insert(context.Background(), p); err != nil {
t.Fatal(err)
}
won1, err := repo.ClaimPaid(context.Background(), "hash2")
if err != nil || !won1 {
t.Fatalf("first claim: won=%v err=%v", won1, err)
}
won2, err := repo.ClaimPaid(context.Background(), "hash2")
if err != nil {
t.Fatal(err)
}
if won2 {
t.Error("second claim should lose race")
}
}