Add payment processing with NWC and subscription-based access control.

- 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.
This commit is contained in:
2025-09-22 17:36:05 +01:00
parent 804e1c9649
commit 7736bb7640
4 changed files with 232 additions and 30 deletions

184
app/payment_processor.go Normal file
View File

@@ -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
}