first commit
This commit is contained in:
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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user