rastrillo / abono Public

Clone
git clone https://amadan.net/rastrillo/abono

Plain git — no account needed to clone.

Download

Download this file

1// Package stripeapi is the few calls abono makes to Stripe's API, and the
2// webhook signature check, by hand rather than through stripe-go.
3package stripeapi
4
5import (
6 "context"
7 "encoding/json"
8 "errors"
9 "fmt"
10 "io"
11 "net/http"
12 "net/url"
13 "strings"
14 "time"
15)
16
17// Client is the few calls abono makes to Stripe's API: a handful of
18// endpoints are less to vet than the SDK, and the fields read are the
19// ones written down here.
20type Client struct {
21 // Key is the account's secret or restricted key. A restricted key
22 // needs: Checkout Sessions and Customer portal write; Subscriptions,
23 // Customers, Prices and Promotion codes read.
24 Key string
25 // Base is the API's address; "" is Stripe's. Tests point it at a fake.
26 Base string
27 // HTTP is the transport; nil is a client with a 30-second timeout.
28 HTTP *http.Client
29}
30
31// APIVersion is pinned so a change to the account's default version in
32// Stripe's dashboard changes nothing here. Webhooks arrive in the
33// endpoint's own version, which is why the handler reads only ids from
34// them and fetches the subscription again through this.
35const APIVersion = "2025-03-31.basil"
36
37// Subscription is what abono reads of one.
38type Subscription struct {
39 ID string `json:"id"`
40 Status string `json:"status"`
41 CancelAtPeriodEnd bool `json:"cancel_at_period_end"`
42 // CurrentPeriodStart and CurrentPeriodEnd moved onto the items in
43 // 2025-03-31.basil; the top-level ones are read for an older account
44 // version's webhooks.
45 CurrentPeriodStart int64 `json:"current_period_start"`
46 CurrentPeriodEnd int64 `json:"current_period_end"`
47 Customer struct {
48 ID string `json:"id"`
49 Email string `json:"email"`
50 Name string `json:"name"`
51 } `json:"customer"`
52 Items struct {
53 Data []struct {
54 CurrentPeriodStart int64 `json:"current_period_start"`
55 CurrentPeriodEnd int64 `json:"current_period_end"`
56 Price struct {
57 ID string `json:"id"`
58 Recurring Recurring `json:"recurring"`
59 } `json:"price"`
60 } `json:"data"`
61 } `json:"items"`
62 Metadata map[string]string `json:"metadata"`
63}
64
65// PeriodEnd is when the period paid for ends, or the zero time.
66func (s Subscription) PeriodEnd() time.Time {
67 end := s.CurrentPeriodEnd
68 for _, it := range s.Items.Data {
69 if it.CurrentPeriodEnd > end {
70 end = it.CurrentPeriodEnd
71 }
72 }
73 if end == 0 {
74 return time.Time{}
75 }
76 return time.Unix(end, 0).UTC()
77}
78
79// PeriodStart is when the current period began, or the zero time. After
80// a failed renewal Stripe has already moved the period on, so this is
81// where the unpaid period starts: the end of the one last paid for.
82//
83// The items are read first (ruling R21): the earliest item start, since
84// the past_due window counts from it and a later one would lengthen it.
85// The top-level start, an older API version's, is read only when no item
86// has one.
87func (s Subscription) PeriodStart() time.Time {
88 var start int64
89 for _, it := range s.Items.Data {
90 if it.CurrentPeriodStart != 0 && (start == 0 || it.CurrentPeriodStart < start) {
91 start = it.CurrentPeriodStart
92 }
93 }
94 if start == 0 {
95 start = s.CurrentPeriodStart
96 }
97 if start == 0 {
98 return time.Time{}
99 }
100 return time.Unix(start, 0).UTC()
101}
102
103// PriceID is the first item's price.
104func (s Subscription) PriceID() (id, interval string) {
105 if len(s.Items.Data) == 0 {
106 return "", ""
107 }
108 p := s.Items.Data[0].Price
109 return p.ID, p.Recurring.Interval
110}
111
112// Recurring is how often the first item's price bills: every count
113// intervals ("month", 12 is yearly in twelve monthly steps, not monthly).
114func (s Subscription) Recurring() (interval string, count int) {
115 if len(s.Items.Data) == 0 {
116 return "", 0
117 }
118 r := s.Items.Data[0].Price.Recurring
119 return r.Interval, r.IntervalCount
120}
121
122// subscriptionWire is a subscription as the API sends it, whose
123// customer is an id unless expanded.
124type subscriptionWire struct {
125 Subscription
126 Customer json.RawMessage `json:"customer"`
127}
128
129func (w subscriptionWire) decode() (Subscription, error) {
130 s := w.Subscription
131 if len(w.Customer) > 0 && w.Customer[0] == '"' {
132 return s, json.Unmarshal(w.Customer, &s.Customer.ID)
133 }
134 if len(w.Customer) > 0 && string(w.Customer) != "null" {
135 return s, json.Unmarshal(w.Customer, &s.Customer)
136 }
137 return s, nil
138}
139
140// GetSubscription fetches one subscription with its customer.
141func (c *Client) GetSubscription(ctx context.Context, id string) (Subscription, error) {
142 var w subscriptionWire
143 if err := c.call(ctx, http.MethodGet, "/v1/subscriptions/"+url.PathEscape(id), url.Values{"expand[]": {"customer"}}, "", &w); err != nil {
144 return Subscription{}, err
145 }
146 return w.decode()
147}
148
149// ListSubscriptions calls each with every subscription on the account, any
150// status, a page at a time.
151func (c *Client) ListSubscriptions(ctx context.Context, each func(Subscription) error) error {
152 after := ""
153 for {
154 q := url.Values{"status": {"all"}, "limit": {"100"}, "expand[]": {"data.customer"}}
155 if after != "" {
156 q.Set("starting_after", after)
157 }
158 var page struct {
159 Data []subscriptionWire `json:"data"`
160 HasMore bool `json:"has_more"`
161 }
162 if err := c.call(ctx, http.MethodGet, "/v1/subscriptions", q, "", &page); err != nil {
163 return err
164 }
165 for _, w := range page.Data {
166 s, err := w.decode()
167 if err != nil {
168 return err
169 }
170 if err := each(s); err != nil {
171 return err
172 }
173 after = s.ID
174 }
175 if !page.HasMore || len(page.Data) == 0 {
176 return nil
177 }
178 }
179}
180
181// Price is a recurring price: what a plan costs and how often.
182type Price struct {
183 ID string `json:"id"`
184 UnitAmount int64 `json:"unit_amount"`
185 Currency string `json:"currency"`
186 Recurring Recurring `json:"recurring"`
187}
188
189// Recurring is how often a price bills: every IntervalCount Intervals.
190// Both are empty for a one-off price.
191type Recurring struct {
192 Interval string `json:"interval"`
193 IntervalCount int `json:"interval_count"`
194}
195
196// GetPrice fetches one price.
197func (c *Client) GetPrice(ctx context.Context, id string) (Price, error) {
198 var p Price
199 return p, c.call(ctx, http.MethodGet, "/v1/prices/"+url.PathEscape(id), nil, "", &p)
200}
201
202// CheckoutSession is a hosted Checkout page, or one that has finished.
203type CheckoutSession struct {
204 ID string `json:"id"`
205 URL string `json:"url"`
206 Status string `json:"status"`
207 // PaymentStatus is paid, unpaid (a debit still processing) or
208 // no_payment_required (a trial, a 100% code). A complete session may
209 // still be unpaid.
210 PaymentStatus string `json:"payment_status"`
211 ClientReferenceID string `json:"client_reference_id"`
212 Subscription string `json:"subscription"`
213 CustomerDetails struct {
214 Email string `json:"email"`
215 } `json:"customer_details"`
216}
217
218// CreateCheckout starts a hosted Checkout with form's parameters.
219func (c *Client) CreateCheckout(ctx context.Context, form url.Values) (CheckoutSession, error) {
220 var s CheckoutSession
221 return s, c.call(ctx, http.MethodPost, "/v1/checkout/sessions", form, "", &s)
222}
223
224// GetCheckout fetches one Checkout session.
225func (c *Client) GetCheckout(ctx context.Context, id string) (CheckoutSession, error) {
226 var s CheckoutSession
227 return s, c.call(ctx, http.MethodGet, "/v1/checkout/sessions/"+url.PathEscape(id), nil, "", &s)
228}
229
230// PortalURL is a Customer Portal session for customer, coming back to
231// returnURL: where a subscriber changes their card, plan, or cancels.
232func (c *Client) PortalURL(ctx context.Context, customer, returnURL string) (string, error) {
233 var s struct {
234 URL string `json:"url"`
235 }
236 err := c.call(ctx, http.MethodPost, "/v1/billing_portal/sessions",
237 url.Values{"customer": {customer}, "return_url": {returnURL}}, "", &s)
238 return s.URL, err
239}
240
241// PromotionCode is the active promotion code with this customer-facing
242// code ("INNOVATE"), or "" when there is none.
243func (c *Client) PromotionCode(ctx context.Context, code string) (string, error) {
244 var page struct {
245 Data []struct {
246 ID string `json:"id"`
247 } `json:"data"`
248 }
249 if err := c.call(ctx, http.MethodGet, "/v1/promotion_codes", url.Values{"code": {code}, "active": {"true"}, "limit": {"1"}}, "", &page); err != nil {
250 return "", err
251 }
252 if len(page.Data) == 0 {
253 return "", nil
254 }
255 return page.Data[0].ID, nil
256}
257
258// Error is Stripe's own refusal.
259type Error struct {
260 Status int
261 Type string `json:"type"`
262 Code string `json:"code"`
263 Message string `json:"message"`
264}
265
266func (e *Error) Error() string {
267 return fmt.Sprintf("stripe: %d %s %s: %s", e.Status, e.Type, e.Code, e.Message)
268}
269
270// NotFound reports whether err is Stripe saying the object doesn't exist.
271func NotFound(err error) bool {
272 var e *Error
273 return errors.As(err, &e) && e.Status == http.StatusNotFound
274}
275
276func (c *Client) call(ctx context.Context, method, path string, form url.Values, idempotencyKey string, out any) error {
277 base := c.Base
278 if base == "" {
279 base = "https://api.stripe.com"
280 }
281 var body io.Reader
282 u := base + path
283 if method == http.MethodGet {
284 if len(form) > 0 {
285 u += "?" + form.Encode()
286 }
287 } else {
288 body = strings.NewReader(form.Encode())
289 }
290 req, err := http.NewRequestWithContext(ctx, method, u, body)
291 if err != nil {
292 return err
293 }
294 req.Header.Set("Authorization", "Bearer "+c.Key)
295 req.Header.Set("Stripe-Version", APIVersion)
296 if body != nil {
297 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
298 }
299 if idempotencyKey != "" {
300 req.Header.Set("Idempotency-Key", idempotencyKey)
301 }
302 client := c.HTTP
303 if client == nil {
304 client = &http.Client{Timeout: 30 * time.Second}
305 }
306 resp, err := client.Do(req)
307 if err != nil {
308 return err
309 }
310 defer resp.Body.Close()
311 data, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
312 if err != nil {
313 return err
314 }
315 if resp.StatusCode/100 != 2 {
316 var e struct {
317 Error Error `json:"error"`
318 }
319 _ = json.Unmarshal(data, &e)
320 e.Error.Status = resp.StatusCode
321 return &e.Error
322 }
323 return json.Unmarshal(data, out)
324}
325