// Package stripeapi is the few calls abono makes to Stripe's API, and the // webhook signature check, by hand rather than through stripe-go. package stripeapi import ( "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strings" "time" ) // Client is the few calls abono makes to Stripe's API: a handful of // endpoints are less to vet than the SDK, and the fields read are the // ones written down here. type Client struct { // Key is the account's secret or restricted key. A restricted key // needs: Checkout Sessions and Customer portal write; Subscriptions, // Customers, Prices and Promotion codes read. Key string // Base is the API's address; "" is Stripe's. Tests point it at a fake. Base string // HTTP is the transport; nil is a client with a 30-second timeout. HTTP *http.Client } // APIVersion is pinned so a change to the account's default version in // Stripe's dashboard changes nothing here. Webhooks arrive in the // endpoint's own version, which is why the handler reads only ids from // them and fetches the subscription again through this. const APIVersion = "2025-03-31.basil" // Subscription is what abono reads of one. type Subscription struct { ID string `json:"id"` Status string `json:"status"` CancelAtPeriodEnd bool `json:"cancel_at_period_end"` // CurrentPeriodStart and CurrentPeriodEnd moved onto the items in // 2025-03-31.basil; the top-level ones are read for an older account // version's webhooks. CurrentPeriodStart int64 `json:"current_period_start"` CurrentPeriodEnd int64 `json:"current_period_end"` Customer struct { ID string `json:"id"` Email string `json:"email"` Name string `json:"name"` } `json:"customer"` Items struct { Data []struct { CurrentPeriodStart int64 `json:"current_period_start"` CurrentPeriodEnd int64 `json:"current_period_end"` Price struct { ID string `json:"id"` Recurring Recurring `json:"recurring"` } `json:"price"` } `json:"data"` } `json:"items"` Metadata map[string]string `json:"metadata"` } // PeriodEnd is when the period paid for ends, or the zero time. func (s Subscription) PeriodEnd() time.Time { end := s.CurrentPeriodEnd for _, it := range s.Items.Data { if it.CurrentPeriodEnd > end { end = it.CurrentPeriodEnd } } if end == 0 { return time.Time{} } return time.Unix(end, 0).UTC() } // PeriodStart is when the current period began, or the zero time. After // a failed renewal Stripe has already moved the period on, so this is // where the unpaid period starts: the end of the one last paid for. // // The items are read first (ruling R21): the earliest item start, since // the past_due window counts from it and a later one would lengthen it. // The top-level start, an older API version's, is read only when no item // has one. func (s Subscription) PeriodStart() time.Time { var start int64 for _, it := range s.Items.Data { if it.CurrentPeriodStart != 0 && (start == 0 || it.CurrentPeriodStart < start) { start = it.CurrentPeriodStart } } if start == 0 { start = s.CurrentPeriodStart } if start == 0 { return time.Time{} } return time.Unix(start, 0).UTC() } // PriceID is the first item's price. func (s Subscription) PriceID() (id, interval string) { if len(s.Items.Data) == 0 { return "", "" } p := s.Items.Data[0].Price return p.ID, p.Recurring.Interval } // Recurring is how often the first item's price bills: every count // intervals ("month", 12 is yearly in twelve monthly steps, not monthly). func (s Subscription) Recurring() (interval string, count int) { if len(s.Items.Data) == 0 { return "", 0 } r := s.Items.Data[0].Price.Recurring return r.Interval, r.IntervalCount } // subscriptionWire is a subscription as the API sends it, whose // customer is an id unless expanded. type subscriptionWire struct { Subscription Customer json.RawMessage `json:"customer"` } func (w subscriptionWire) decode() (Subscription, error) { s := w.Subscription if len(w.Customer) > 0 && w.Customer[0] == '"' { return s, json.Unmarshal(w.Customer, &s.Customer.ID) } if len(w.Customer) > 0 && string(w.Customer) != "null" { return s, json.Unmarshal(w.Customer, &s.Customer) } return s, nil } // GetSubscription fetches one subscription with its customer. func (c *Client) GetSubscription(ctx context.Context, id string) (Subscription, error) { var w subscriptionWire if err := c.call(ctx, http.MethodGet, "/v1/subscriptions/"+url.PathEscape(id), url.Values{"expand[]": {"customer"}}, "", &w); err != nil { return Subscription{}, err } return w.decode() } // ListSubscriptions calls each with every subscription on the account, any // status, a page at a time. func (c *Client) ListSubscriptions(ctx context.Context, each func(Subscription) error) error { after := "" for { q := url.Values{"status": {"all"}, "limit": {"100"}, "expand[]": {"data.customer"}} if after != "" { q.Set("starting_after", after) } var page struct { Data []subscriptionWire `json:"data"` HasMore bool `json:"has_more"` } if err := c.call(ctx, http.MethodGet, "/v1/subscriptions", q, "", &page); err != nil { return err } for _, w := range page.Data { s, err := w.decode() if err != nil { return err } if err := each(s); err != nil { return err } after = s.ID } if !page.HasMore || len(page.Data) == 0 { return nil } } } // Price is a recurring price: what a plan costs and how often. type Price struct { ID string `json:"id"` UnitAmount int64 `json:"unit_amount"` Currency string `json:"currency"` Recurring Recurring `json:"recurring"` } // Recurring is how often a price bills: every IntervalCount Intervals. // Both are empty for a one-off price. type Recurring struct { Interval string `json:"interval"` IntervalCount int `json:"interval_count"` } // GetPrice fetches one price. func (c *Client) GetPrice(ctx context.Context, id string) (Price, error) { var p Price return p, c.call(ctx, http.MethodGet, "/v1/prices/"+url.PathEscape(id), nil, "", &p) } // CheckoutSession is a hosted Checkout page, or one that has finished. type CheckoutSession struct { ID string `json:"id"` URL string `json:"url"` Status string `json:"status"` // PaymentStatus is paid, unpaid (a debit still processing) or // no_payment_required (a trial, a 100% code). A complete session may // still be unpaid. PaymentStatus string `json:"payment_status"` ClientReferenceID string `json:"client_reference_id"` Subscription string `json:"subscription"` CustomerDetails struct { Email string `json:"email"` } `json:"customer_details"` } // CreateCheckout starts a hosted Checkout with form's parameters. func (c *Client) CreateCheckout(ctx context.Context, form url.Values) (CheckoutSession, error) { var s CheckoutSession return s, c.call(ctx, http.MethodPost, "/v1/checkout/sessions", form, "", &s) } // GetCheckout fetches one Checkout session. func (c *Client) GetCheckout(ctx context.Context, id string) (CheckoutSession, error) { var s CheckoutSession return s, c.call(ctx, http.MethodGet, "/v1/checkout/sessions/"+url.PathEscape(id), nil, "", &s) } // PortalURL is a Customer Portal session for customer, coming back to // returnURL: where a subscriber changes their card, plan, or cancels. func (c *Client) PortalURL(ctx context.Context, customer, returnURL string) (string, error) { var s struct { URL string `json:"url"` } err := c.call(ctx, http.MethodPost, "/v1/billing_portal/sessions", url.Values{"customer": {customer}, "return_url": {returnURL}}, "", &s) return s.URL, err } // PromotionCode is the active promotion code with this customer-facing // code ("INNOVATE"), or "" when there is none. func (c *Client) PromotionCode(ctx context.Context, code string) (string, error) { var page struct { Data []struct { ID string `json:"id"` } `json:"data"` } if err := c.call(ctx, http.MethodGet, "/v1/promotion_codes", url.Values{"code": {code}, "active": {"true"}, "limit": {"1"}}, "", &page); err != nil { return "", err } if len(page.Data) == 0 { return "", nil } return page.Data[0].ID, nil } // Error is Stripe's own refusal. type Error struct { Status int Type string `json:"type"` Code string `json:"code"` Message string `json:"message"` } func (e *Error) Error() string { return fmt.Sprintf("stripe: %d %s %s: %s", e.Status, e.Type, e.Code, e.Message) } // NotFound reports whether err is Stripe saying the object doesn't exist. func NotFound(err error) bool { var e *Error return errors.As(err, &e) && e.Status == http.StatusNotFound } func (c *Client) call(ctx context.Context, method, path string, form url.Values, idempotencyKey string, out any) error { base := c.Base if base == "" { base = "https://api.stripe.com" } var body io.Reader u := base + path if method == http.MethodGet { if len(form) > 0 { u += "?" + form.Encode() } } else { body = strings.NewReader(form.Encode()) } req, err := http.NewRequestWithContext(ctx, method, u, body) if err != nil { return err } req.Header.Set("Authorization", "Bearer "+c.Key) req.Header.Set("Stripe-Version", APIVersion) if body != nil { req.Header.Set("Content-Type", "application/x-www-form-urlencoded") } if idempotencyKey != "" { req.Header.Set("Idempotency-Key", idempotencyKey) } client := c.HTTP if client == nil { client = &http.Client{Timeout: 30 * time.Second} } resp, err := client.Do(req) if err != nil { return err } defer resp.Body.Close() data, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20)) if err != nil { return err } if resp.StatusCode/100 != 2 { var e struct { Error Error `json:"error"` } _ = json.Unmarshal(data, &e) e.Error.Status = resp.StatusCode return &e.Error } return json.Unmarshal(data, out) }