stripe.go (4787 bytes)
1 package main 2 3 import ( 4 "crypto/hmac" 5 "crypto/sha256" 6 "encoding/hex" 7 "encoding/json" 8 "errors" 9 "fmt" 10 "net/http" 11 "net/url" 12 "strconv" 13 "strings" 14 "time" 15 ) 16 17 // Stripe calls the few parts of the Stripe API that billing needs. The API 18 // takes form fields and gives JSON, so the standard library is enough. 19 type Stripe struct { 20 Key string // secret API key 21 Webhook string // signing secret of the webhook endpoint 22 API string // https://api.stripe.com; tests replace it 23 Client *http.Client 24 } 25 26 func (s *Stripe) call(method, path string, form url.Values) (map[string]any, error) { 27 req, err := http.NewRequest(method, s.API+path, strings.NewReader(form.Encode())) 28 if err != nil { 29 return nil, err 30 } 31 req.SetBasicAuth(s.Key, "") 32 req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 33 resp, err := s.Client.Do(req) 34 if err != nil { 35 return nil, err 36 } 37 defer resp.Body.Close() 38 var out map[string]any 39 if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { 40 return nil, fmt.Errorf("stripe %s %s: %s", method, path, resp.Status) 41 } 42 if resp.StatusCode >= 300 { 43 msg := resp.Status 44 if e, ok := out["error"].(map[string]any); ok { 45 msg, _ = e["message"].(string) 46 } 47 return nil, fmt.Errorf("stripe %s %s: %s", method, path, msg) 48 } 49 return out, nil 50 } 51 52 // checkout starts a Stripe Checkout session for a subscription to price and 53 // gives the address of its page. After the payment, or without it, Stripe 54 // sends the customer to back. 55 func (s *Stripe) checkout(a Account, price, back string) (string, error) { 56 form := url.Values{ 57 "mode": {"subscription"}, 58 "line_items[0][price]": {price}, 59 "line_items[0][quantity]": {"1"}, 60 "client_reference_id": {a.ID}, 61 "subscription_data[metadata][account]": {a.ID}, 62 "success_url": {back}, 63 "cancel_url": {back}, 64 } 65 if a.Customer != "" { 66 form.Set("customer", a.Customer) 67 } else { 68 form.Set("customer_email", a.Email) 69 } 70 out, err := s.call("POST", "/v1/checkout/sessions", form) 71 if err != nil { 72 return "", err 73 } 74 u, _ := out["url"].(string) 75 if u == "" { 76 return "", errors.New("stripe gave no checkout address") 77 } 78 return u, nil 79 } 80 81 // portal gives the address of a Stripe customer portal session, where the 82 // customer changes the card, sees invoices or cancels. The portal links 83 // back to back. 84 func (s *Stripe) portal(customer, back string) (string, error) { 85 out, err := s.call("POST", "/v1/billing_portal/sessions", 86 url.Values{"customer": {customer}, "return_url": {back}}) 87 if err != nil { 88 return "", err 89 } 90 u, _ := out["url"].(string) 91 if u == "" { 92 return "", errors.New("stripe gave no portal address") 93 } 94 return u, nil 95 } 96 97 // deleteCustomer deletes the customer in Stripe. That ends the 98 // subscriptions of the customer at once. 99 func (s *Stripe) deleteCustomer(customer string) error { 100 _, err := s.call("DELETE", "/v1/customers/"+url.PathEscape(customer), nil) 101 return err 102 } 103 104 // label gives a price as text, such as "$3 a month". 105 func (s *Stripe) label(price string) (string, error) { 106 out, err := s.call("GET", "/v1/prices/"+url.PathEscape(price), nil) 107 if err != nil { 108 return "", err 109 } 110 amount, _ := out["unit_amount"].(float64) 111 currency, _ := out["currency"].(string) 112 interval := "" 113 if r, ok := out["recurring"].(map[string]any); ok { 114 interval, _ = r["interval"].(string) 115 } 116 return money(int64(amount), currency) + " a " + interval, nil 117 } 118 119 func money(cents int64, currency string) string { 120 n := strconv.FormatInt(cents/100, 10) 121 if cents%100 != 0 { 122 n = fmt.Sprintf("%d.%02d", cents/100, cents%100) 123 } 124 if currency == "usd" { 125 return "$" + n 126 } 127 return n + " " + strings.ToUpper(currency) 128 } 129 130 var errSignature = errors.New("bad Stripe signature") 131 132 // verify checks the Stripe-Signature header of a webhook request: an 133 // HMAC-SHA256 of "<time>.<body>" with the signing secret. A request older 134 // than five minutes is refused, so that a copy cannot be sent again later. 135 func verify(body []byte, header, secret string, now time.Time) error { 136 var t string 137 var sigs []string 138 for _, part := range strings.Split(header, ",") { 139 k, v, _ := strings.Cut(strings.TrimSpace(part), "=") 140 switch k { 141 case "t": 142 t = v 143 case "v1": 144 sigs = append(sigs, v) 145 } 146 } 147 ts, err := strconv.ParseInt(t, 10, 64) 148 if err != nil || secret == "" { 149 return errSignature 150 } 151 if d := now.Sub(time.Unix(ts, 0)); d > 5*time.Minute || d < -5*time.Minute { 152 return errSignature 153 } 154 mac := hmac.New(sha256.New, []byte(secret)) 155 mac.Write([]byte(t + ".")) 156 mac.Write(body) 157 want := mac.Sum(nil) 158 for _, sig := range sigs { 159 got, err := hex.DecodeString(sig) 160 if err == nil && hmac.Equal(got, want) { 161 return nil 162 } 163 } 164 return errSignature 165 }