stripe.go (4720 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. 54 func (s *Stripe) checkout(a Account, price, site string) (string, error) { 55 form := url.Values{ 56 "mode": {"subscription"}, 57 "line_items[0][price]": {price}, 58 "line_items[0][quantity]": {"1"}, 59 "client_reference_id": {a.ID}, 60 "subscription_data[metadata][account]": {a.ID}, 61 "success_url": {site + "/account"}, 62 "cancel_url": {site + "/account"}, 63 } 64 if a.Customer != "" { 65 form.Set("customer", a.Customer) 66 } else { 67 form.Set("customer_email", a.Email) 68 } 69 out, err := s.call("POST", "/v1/checkout/sessions", form) 70 if err != nil { 71 return "", err 72 } 73 u, _ := out["url"].(string) 74 if u == "" { 75 return "", errors.New("stripe gave no checkout address") 76 } 77 return u, nil 78 } 79 80 // portal gives the address of a Stripe customer portal session, where the 81 // customer changes the card, sees invoices or cancels. 82 func (s *Stripe) portal(customer, site string) (string, error) { 83 out, err := s.call("POST", "/v1/billing_portal/sessions", 84 url.Values{"customer": {customer}, "return_url": {site + "/account"}}) 85 if err != nil { 86 return "", err 87 } 88 u, _ := out["url"].(string) 89 if u == "" { 90 return "", errors.New("stripe gave no portal address") 91 } 92 return u, nil 93 } 94 95 // deleteCustomer deletes the customer in Stripe. That ends the 96 // subscriptions of the customer at once. 97 func (s *Stripe) deleteCustomer(customer string) error { 98 _, err := s.call("DELETE", "/v1/customers/"+url.PathEscape(customer), nil) 99 return err 100 } 101 102 // label gives a price as text, such as "$3 a month". 103 func (s *Stripe) label(price string) (string, error) { 104 out, err := s.call("GET", "/v1/prices/"+url.PathEscape(price), nil) 105 if err != nil { 106 return "", err 107 } 108 amount, _ := out["unit_amount"].(float64) 109 currency, _ := out["currency"].(string) 110 interval := "" 111 if r, ok := out["recurring"].(map[string]any); ok { 112 interval, _ = r["interval"].(string) 113 } 114 return money(int64(amount), currency) + " a " + interval, nil 115 } 116 117 func money(cents int64, currency string) string { 118 n := strconv.FormatInt(cents/100, 10) 119 if cents%100 != 0 { 120 n = fmt.Sprintf("%d.%02d", cents/100, cents%100) 121 } 122 if currency == "usd" { 123 return "$" + n 124 } 125 return n + " " + strings.ToUpper(currency) 126 } 127 128 var errSignature = errors.New("bad Stripe signature") 129 130 // verify checks the Stripe-Signature header of a webhook request: an 131 // HMAC-SHA256 of "<time>.<body>" with the signing secret. A request older 132 // than five minutes is refused, so that a copy cannot be sent again later. 133 func verify(body []byte, header, secret string, now time.Time) error { 134 var t string 135 var sigs []string 136 for _, part := range strings.Split(header, ",") { 137 k, v, _ := strings.Cut(strings.TrimSpace(part), "=") 138 switch k { 139 case "t": 140 t = v 141 case "v1": 142 sigs = append(sigs, v) 143 } 144 } 145 ts, err := strconv.ParseInt(t, 10, 64) 146 if err != nil || secret == "" { 147 return errSignature 148 } 149 if d := now.Sub(time.Unix(ts, 0)); d > 5*time.Minute || d < -5*time.Minute { 150 return errSignature 151 } 152 mac := hmac.New(sha256.New, []byte(secret)) 153 mac.Write([]byte(t + ".")) 154 mac.Write(body) 155 want := mac.Sum(nil) 156 for _, sig := range sigs { 157 got, err := hex.DecodeString(sig) 158 if err == nil && hmac.Equal(got, want) { 159 return nil 160 } 161 } 162 return errSignature 163 }