Browse Source
- Implemented `PaymentProcessor` to handle NWC payments and extend user subscriptions. - Added configuration options for NWC URI, subscription pricing, and enablement. - Updated server to initialize and manage the payment processor.main
4 changed files with 232 additions and 30 deletions
@ -0,0 +1,184 @@ |
|||||||
|
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 |
||||||
|
} |
||||||
Loading…
Reference in new issue