payment.go raw

   1  package bridge
   2  
   3  import (
   4  	"context"
   5  	"fmt"
   6  	"time"
   7  
   8  	"next.orly.dev/pkg/protocol/nwc"
   9  )
  10  
  11  // NWCRequester abstracts the NWC client's Request method for testing.
  12  type NWCRequester interface {
  13  	Request(ctx context.Context, method string, params, result any) error
  14  }
  15  
  16  // PaymentProcessor wraps a NWC client for bridge subscription payments.
  17  type PaymentProcessor struct {
  18  	client            NWCRequester
  19  	monthlyPriceMSats int64
  20  }
  21  
  22  // NewPaymentProcessor creates a payment processor with the given NWC URI
  23  // and monthly price in satoshis.
  24  func NewPaymentProcessor(nwcURI string, monthlyPriceSats int64) (*PaymentProcessor, error) {
  25  	client, err := nwc.NewClient(nwcURI)
  26  	if err != nil {
  27  		return nil, fmt.Errorf("create NWC client: %w", err)
  28  	}
  29  
  30  	return &PaymentProcessor{
  31  		client:            client,
  32  		monthlyPriceMSats: monthlyPriceSats * 1000, // convert sats to msats
  33  	}, nil
  34  }
  35  
  36  // NewPaymentProcessorWithClient creates a payment processor with a provided
  37  // NWCRequester. This is useful for testing or custom NWC implementations.
  38  func NewPaymentProcessorWithClient(client NWCRequester, monthlyPriceSats int64) *PaymentProcessor {
  39  	return &PaymentProcessor{
  40  		client:            client,
  41  		monthlyPriceMSats: monthlyPriceSats * 1000,
  42  	}
  43  }
  44  
  45  // Invoice represents a Lightning invoice returned by the wallet.
  46  type Invoice struct {
  47  	Bolt11      string `json:"invoice"`
  48  	PaymentHash string `json:"payment_hash"`
  49  	Amount      int64  `json:"amount"` // millisatoshis
  50  	Description string `json:"description"`
  51  	CreatedAt   int64  `json:"created_at"`
  52  	ExpiresAt   int64  `json:"expires_at"`
  53  }
  54  
  55  // InvoiceStatus represents the status of a looked-up invoice.
  56  type InvoiceStatus struct {
  57  	Bolt11      string `json:"invoice"`
  58  	PaymentHash string `json:"payment_hash"`
  59  	Amount      int64  `json:"amount"`
  60  	Preimage    string `json:"preimage,omitempty"`
  61  	SettledAt   int64  `json:"settled_at,omitempty"`
  62  	IsPaid      bool   `json:"-"`
  63  }
  64  
  65  // CreateSubscriptionInvoice creates a Lightning invoice for a one-month
  66  // bridge subscription at the default price.
  67  func (pp *PaymentProcessor) CreateSubscriptionInvoice(ctx context.Context) (*Invoice, error) {
  68  	return pp.CreateInvoice(ctx, pp.monthlyPriceMSats/1000) // convert msats back to sats
  69  }
  70  
  71  // CreateInvoice creates a Lightning invoice for the given amount in satoshis.
  72  func (pp *PaymentProcessor) CreateInvoice(ctx context.Context, amountSats int64) (*Invoice, error) {
  73  	params := map[string]any{
  74  		"amount":      amountSats * 1000, // NWC uses millisatoshis
  75  		"description": "Marmot Email Bridge — 1 month subscription",
  76  	}
  77  
  78  	var result Invoice
  79  	if err := pp.client.Request(ctx, "make_invoice", params, &result); err != nil {
  80  		return nil, fmt.Errorf("make_invoice: %w", err)
  81  	}
  82  
  83  	return &result, nil
  84  }
  85  
  86  // LookupInvoice checks the payment status of an invoice by its payment hash.
  87  func (pp *PaymentProcessor) LookupInvoice(ctx context.Context, paymentHash string) (*InvoiceStatus, error) {
  88  	params := map[string]any{
  89  		"payment_hash": paymentHash,
  90  	}
  91  
  92  	var result InvoiceStatus
  93  	if err := pp.client.Request(ctx, "lookup_invoice", params, &result); err != nil {
  94  		return nil, fmt.Errorf("lookup_invoice: %w", err)
  95  	}
  96  
  97  	// Determine paid status: settled_at > 0 or preimage present
  98  	result.IsPaid = result.SettledAt > 0 || result.Preimage != ""
  99  
 100  	return &result, nil
 101  }
 102  
 103  // WaitForPayment polls the invoice until paid or the context is cancelled.
 104  // Returns the settled invoice status on success.
 105  func (pp *PaymentProcessor) WaitForPayment(ctx context.Context, paymentHash string, pollInterval time.Duration) (*InvoiceStatus, error) {
 106  	if pollInterval <= 0 {
 107  		pollInterval = 5 * time.Second
 108  	}
 109  
 110  	ticker := time.NewTicker(pollInterval)
 111  	defer ticker.Stop()
 112  
 113  	for {
 114  		select {
 115  		case <-ctx.Done():
 116  			return nil, ctx.Err()
 117  		case <-ticker.C:
 118  			status, err := pp.LookupInvoice(ctx, paymentHash)
 119  			if err != nil {
 120  				// Log but keep polling — transient NWC errors shouldn't abort
 121  				continue
 122  			}
 123  			if status.IsPaid {
 124  				return status, nil
 125  			}
 126  		}
 127  	}
 128  }
 129