| 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. |
| 3 | package stripeapi |
| 4 | |
| 5 | import ( |
| 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. |
| 20 | type 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. |
| 35 | const APIVersion = "2025-03-31.basil" |
| 36 | |
| 37 | // Subscription is what abono reads of one. |
| 38 | type 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. |
| 66 | func (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. |
| 87 | func (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. |
| 104 | func (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). |
| 114 | func (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. |
| 124 | type subscriptionWire struct { |
| 125 | Subscription |
| 126 | Customer json.RawMessage `json:"customer"` |
| 127 | } |
| 128 | |
| 129 | func (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. |
| 141 | func (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. |
| 151 | func (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. |
| 182 | type 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. |
| 191 | type Recurring struct { |
| 192 | Interval string `json:"interval"` |
| 193 | IntervalCount int `json:"interval_count"` |
| 194 | } |
| 195 | |
| 196 | // GetPrice fetches one price. |
| 197 | func (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. |
| 203 | type 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. |
| 219 | func (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. |
| 225 | func (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. |
| 232 | func (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. |
| 243 | func (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. |
| 259 | type Error struct { |
| 260 | Status int |
| 261 | Type string `json:"type"` |
| 262 | Code string `json:"code"` |
| 263 | Message string `json:"message"` |
| 264 | } |
| 265 | |
| 266 | func (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. |
| 271 | func NotFound(err error) bool { |
| 272 | var e *Error |
| 273 | return errors.As(err, &e) && e.Status == http.StatusNotFound |
| 274 | } |
| 275 | |
| 276 | func (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 | |