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 (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 }