first commit
This commit is contained in:
69
internal/payments/dispatch.go
Normal file
69
internal/payments/dispatch.go
Normal 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
173
internal/payments/worker.go
Normal 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)
|
||||
}
|
||||
|
||||
180
internal/payments/worker_test.go
Normal file
180
internal/payments/worker_test.go
Normal 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: ¤t}
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user