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