You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
184 lines
4.5 KiB
184 lines
4.5 KiB
package app |
|
|
|
import ( |
|
"context" |
|
"fmt" |
|
"strings" |
|
"sync" |
|
|
|
"lol.mleku.dev/chk" |
|
"lol.mleku.dev/log" |
|
"next.orly.dev/app/config" |
|
"next.orly.dev/pkg/database" |
|
"next.orly.dev/pkg/encoders/bech32encoding" |
|
"next.orly.dev/pkg/protocol/nwc" |
|
) |
|
|
|
// PaymentProcessor handles NWC payment notifications and updates subscriptions |
|
type PaymentProcessor struct { |
|
nwcClient *nwc.Client |
|
db *database.D |
|
config *config.C |
|
ctx context.Context |
|
cancel context.CancelFunc |
|
wg sync.WaitGroup |
|
} |
|
|
|
// NewPaymentProcessor creates a new payment processor |
|
func NewPaymentProcessor( |
|
ctx context.Context, cfg *config.C, db *database.D, |
|
) (pp *PaymentProcessor, err error) { |
|
if cfg.NWCUri == "" { |
|
return nil, fmt.Errorf("NWC URI not configured") |
|
} |
|
|
|
var nwcClient *nwc.Client |
|
if nwcClient, err = nwc.NewClient(cfg.NWCUri); chk.E(err) { |
|
return nil, fmt.Errorf("failed to create NWC client: %w", err) |
|
} |
|
|
|
c, cancel := context.WithCancel(ctx) |
|
|
|
pp = &PaymentProcessor{ |
|
nwcClient: nwcClient, |
|
db: db, |
|
config: cfg, |
|
ctx: c, |
|
cancel: cancel, |
|
} |
|
|
|
return pp, nil |
|
} |
|
|
|
// Start begins listening for payment notifications |
|
func (pp *PaymentProcessor) Start() error { |
|
pp.wg.Add(1) |
|
go func() { |
|
defer pp.wg.Done() |
|
if err := pp.listenForPayments(); err != nil { |
|
log.E.F("payment processor error: %v", err) |
|
} |
|
}() |
|
return nil |
|
} |
|
|
|
// Stop gracefully stops the payment processor |
|
func (pp *PaymentProcessor) Stop() { |
|
if pp.cancel != nil { |
|
pp.cancel() |
|
} |
|
pp.wg.Wait() |
|
} |
|
|
|
// listenForPayments subscribes to NWC notifications and processes payments |
|
func (pp *PaymentProcessor) listenForPayments() error { |
|
return pp.nwcClient.SubscribeNotifications(pp.ctx, pp.handleNotification) |
|
} |
|
|
|
// handleNotification processes incoming payment notifications |
|
func (pp *PaymentProcessor) handleNotification( |
|
notificationType string, notification map[string]any, |
|
) error { |
|
// Only process payment_received notifications |
|
if notificationType != "payment_received" { |
|
return nil |
|
} |
|
|
|
amount, ok := notification["amount"].(float64) |
|
if !ok { |
|
return fmt.Errorf("invalid amount") |
|
} |
|
|
|
description, _ := notification["description"].(string) |
|
userNpub := pp.extractNpubFromDescription(description) |
|
if userNpub == "" { |
|
if metadata, ok := notification["metadata"].(map[string]any); ok { |
|
if npubField, ok := metadata["npub"].(string); ok { |
|
userNpub = npubField |
|
} |
|
} |
|
} |
|
if userNpub == "" { |
|
return fmt.Errorf("no npub in payment description") |
|
} |
|
|
|
pubkey, err := pp.npubToPubkey(userNpub) |
|
if err != nil { |
|
return fmt.Errorf("invalid npub: %w", err) |
|
} |
|
|
|
satsReceived := int64(amount / 1000) |
|
monthlyPrice := pp.config.MonthlyPriceSats |
|
if monthlyPrice <= 0 { |
|
monthlyPrice = 6000 |
|
} |
|
|
|
days := int((float64(satsReceived) / float64(monthlyPrice)) * 30) |
|
if days < 1 { |
|
return fmt.Errorf("payment amount too small") |
|
} |
|
|
|
if err := pp.db.ExtendSubscription(pubkey, days); err != nil { |
|
return fmt.Errorf("failed to extend subscription: %w", err) |
|
} |
|
|
|
// Record payment history |
|
invoice, _ := notification["invoice"].(string) |
|
preimage, _ := notification["preimage"].(string) |
|
if err := pp.db.RecordPayment( |
|
pubkey, satsReceived, invoice, preimage, |
|
); err != nil { |
|
log.E.F("failed to record payment: %v", err) |
|
} |
|
|
|
log.I.F( |
|
"payment processed: %s %d sats -> %d days", userNpub, satsReceived, |
|
days, |
|
) |
|
|
|
return nil |
|
} |
|
|
|
// extractNpubFromDescription extracts an npub from the payment description |
|
func (pp *PaymentProcessor) extractNpubFromDescription(description string) string { |
|
// Look for npub1... pattern in the description |
|
parts := strings.Fields(description) |
|
for _, part := range parts { |
|
if strings.HasPrefix(part, "npub1") && len(part) == 63 { |
|
return part |
|
} |
|
} |
|
|
|
// Also check if the entire description is just an npub |
|
description = strings.TrimSpace(description) |
|
if strings.HasPrefix(description, "npub1") && len(description) == 63 { |
|
return description |
|
} |
|
|
|
return "" |
|
} |
|
|
|
// npubToPubkey converts an npub string to pubkey bytes |
|
func (pp *PaymentProcessor) npubToPubkey(npubStr string) ([]byte, error) { |
|
// Validate npub format |
|
if !strings.HasPrefix(npubStr, "npub1") || len(npubStr) != 63 { |
|
return nil, fmt.Errorf("invalid npub format") |
|
} |
|
|
|
// Decode using bech32encoding |
|
prefix, value, err := bech32encoding.Decode([]byte(npubStr)) |
|
if err != nil { |
|
return nil, fmt.Errorf("failed to decode npub: %w", err) |
|
} |
|
|
|
if !strings.EqualFold(string(prefix), "npub") { |
|
return nil, fmt.Errorf("invalid prefix: %s", string(prefix)) |
|
} |
|
|
|
pubkey, ok := value.([]byte) |
|
if !ok { |
|
return nil, fmt.Errorf("decoded value is not []byte") |
|
} |
|
|
|
return pubkey, nil |
|
}
|
|
|