Recently Written · git

sbm-sync

Sync server for sbm bookmark files: one small Go program, plain files, AGPL

git clone https://github.com/equwal/sbm-sync

Log | Files | Refs


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 }