469 lines
16 KiB
Go
469 lines
16 KiB
Go
package stripeapi
|
|
|
|
import (
|
|
"context"
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// apiBase is Stripe's REST API root. Every call in this file talks
|
|
// directly to it over net/http — enkly deliberately has no dependency
|
|
// on Stripe's official Go SDK (see the comment on config/env.go about
|
|
// keeping go.mod small), and the handful of endpoints subscriptions
|
|
// need (Checkout Sessions, Billing Portal Sessions, webhook signature
|
|
// verification) are simple enough not to need it.
|
|
const apiBase = "https://api.stripe.com/v1"
|
|
|
|
// Info is the subset of domain.PlanInfo EnsurePrice needs to find-or-
|
|
// create a plan's monthly price in Stripe. Kept as its own small type
|
|
// here (rather than importing services/billing/internal/domain) so
|
|
// this package stays a standalone Stripe API client with zero
|
|
// dependency on billing's own domain model — the same separation the
|
|
// monolith had between internal/billing (which owned both) and nothing
|
|
// else, now made explicit by the package boundary.
|
|
type Info struct {
|
|
Plan string
|
|
Name string
|
|
PriceEUR int
|
|
}
|
|
|
|
// Client is a small Stripe API client, authenticated with a secret key
|
|
// (STRIPE_SECRET_KEY). Safe for concurrent use.
|
|
type Client struct {
|
|
SecretKey string
|
|
HTTPClient *http.Client
|
|
}
|
|
|
|
// New builds a Client. secretKey is Stripe's secret key (sk_test_… or
|
|
// sk_live_…) — never the publishable key, and never exposed to the
|
|
// browser.
|
|
func New(secretKey string) *Client {
|
|
return &Client{
|
|
SecretKey: secretKey,
|
|
HTTPClient: &http.Client{Timeout: 15 * time.Second},
|
|
}
|
|
}
|
|
|
|
// Configured reports whether a secret key was supplied, so callers can
|
|
// give a clear error instead of a confusing Stripe 401 when it wasn't.
|
|
func (c *Client) Configured() bool { return c != nil && c.SecretKey != "" }
|
|
|
|
// post sends a application/x-www-form-urlencoded POST to a Stripe
|
|
// endpoint (Stripe's API accepts form-encoded bodies for every
|
|
// resource, including nested params via PHP-style bracket keys — see
|
|
// the callers below) and decodes the JSON response into out.
|
|
func (c *Client) post(ctx context.Context, path string, form url.Values, out any) error {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiBase+path, strings.NewReader(form.Encode()))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.do(req, out)
|
|
}
|
|
|
|
func (c *Client) get(ctx context.Context, path string, out any) error {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiBase+path, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.do(req, out)
|
|
}
|
|
|
|
// APIError is what do() returns for any non-2xx response from Stripe —
|
|
// kept as its own type (rather than just fmt.Errorf-ing the message in)
|
|
// specifically so callers showing this to a self-hosting admin (see
|
|
// internal/handlers/billing.go's subscriptionError) can display
|
|
// Message on its own: it's Stripe's own human-readable explanation
|
|
// (e.g. "No such price: 'prod_ABC123'; a similar object exists in live
|
|
// mode, but a test mode key was used to make this request.") and is
|
|
// almost always more useful for fixing a misconfigured Price ID, a
|
|
// test/live key mismatch, etc. than a generic "something went wrong".
|
|
type APIError struct {
|
|
Path string // the Stripe API path that was called, e.g. "/checkout/sessions"
|
|
Status int
|
|
Type string // Stripe's error "type", e.g. "invalid_request_error"
|
|
Message string // Stripe's own explanation — safe and useful to show as-is
|
|
}
|
|
|
|
func (e *APIError) Error() string {
|
|
if e.Message != "" {
|
|
return fmt.Sprintf("stripe %s: %s", e.Path, e.Message)
|
|
}
|
|
return fmt.Sprintf("stripe %s: status %d", e.Path, e.Status)
|
|
}
|
|
|
|
// Explain extracts a clean, safe-to-display message from an error one
|
|
// of this package's Client methods returned: Stripe's own explanation
|
|
// for an *APIError (a bad Price ID, a test/live key mismatch, an
|
|
// invalid API key, ...), or the error's own message otherwise (a
|
|
// network/DNS failure reaching Stripe, a malformed local response).
|
|
// Never includes the secret key — it's only ever sent in a request
|
|
// header, never echoed back into an error — so this is safe to show
|
|
// directly to whoever administers this server.
|
|
func Explain(err error) string {
|
|
if err == nil {
|
|
return ""
|
|
}
|
|
var apiErr *APIError
|
|
if errors.As(err, &apiErr) && apiErr.Message != "" {
|
|
return apiErr.Message
|
|
}
|
|
return err.Error()
|
|
}
|
|
|
|
func (c *Client) do(req *http.Request, out any) error {
|
|
if c.SecretKey == "" {
|
|
return errors.New("billing: STRIPE_SECRET_KEY is not configured")
|
|
}
|
|
req.SetBasicAuth(c.SecretKey, "") // Stripe auth: secret key as the HTTP Basic username, empty password
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
// Pin an API version so Stripe's response shape (e.g. where
|
|
// current_period_end lives on a subscription) can't drift under us
|
|
// between requests. Any recent version works; this one is well
|
|
// after Price/nested line_items became standard.
|
|
req.Header.Set("Stripe-Version", "2024-06-20")
|
|
|
|
resp, err := c.HTTPClient.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("billing: request %s: %w", req.URL.Path, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
|
if err != nil {
|
|
return fmt.Errorf("billing: read response: %w", err)
|
|
}
|
|
if resp.StatusCode >= 300 {
|
|
var parsed struct {
|
|
Error struct {
|
|
Message string `json:"message"`
|
|
Type string `json:"type"`
|
|
} `json:"error"`
|
|
}
|
|
_ = json.Unmarshal(body, &parsed)
|
|
return &APIError{Path: req.URL.Path, Status: resp.StatusCode, Type: parsed.Error.Type, Message: parsed.Error.Message}
|
|
}
|
|
if out == nil {
|
|
return nil
|
|
}
|
|
if err := json.Unmarshal(body, out); err != nil {
|
|
return fmt.Errorf("billing: decode response: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Customers
|
|
// ---------------------------------------------------------------------
|
|
|
|
// CreateCustomer creates a Stripe Customer for a newly-paying user.
|
|
// userID is stashed in metadata so a webhook can map a Stripe object
|
|
// back to an enkly account even if the subscriptions row lookup by
|
|
// stripe_customer_id ever misses.
|
|
func (c *Client) CreateCustomer(ctx context.Context, email string, userID int64) (string, error) {
|
|
form := url.Values{
|
|
"email": {email},
|
|
"metadata[user_id]": {strconv.FormatInt(userID, 10)},
|
|
}
|
|
var out struct {
|
|
ID string `json:"id"`
|
|
}
|
|
if err := c.post(ctx, "/customers", form, &out); err != nil {
|
|
return "", err
|
|
}
|
|
return out.ID, nil
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Prices
|
|
// ---------------------------------------------------------------------
|
|
|
|
// EnsurePrice returns the Stripe Price ID for a paid plan, creating the
|
|
// product and recurring monthly EUR price on first use. Prices are found
|
|
// again on later runs through a Stripe "lookup key" (enkly_<plan>_monthly),
|
|
// so calling this on every startup never creates duplicates. This is what
|
|
// lets the app work when STRIPE_PRICE_* is unset or wrongly set to an
|
|
// amount like "5" instead of a price_… ID.
|
|
func (c *Client) EnsurePrice(ctx context.Context, info Info) (string, error) {
|
|
lookupKey := "enkly_" + string(info.Plan) + "_monthly"
|
|
|
|
var found struct {
|
|
Data []struct {
|
|
ID string `json:"id"`
|
|
UnitAmount int `json:"unit_amount"`
|
|
Currency string `json:"currency"`
|
|
} `json:"data"`
|
|
}
|
|
q := url.Values{"lookup_keys[]": {lookupKey}, "active": {"true"}}
|
|
if err := c.get(ctx, "/prices?"+q.Encode(), &found); err != nil {
|
|
return "", err
|
|
}
|
|
for _, pr := range found.Data {
|
|
if pr.UnitAmount == info.PriceEUR*100 && pr.Currency == "eur" {
|
|
return pr.ID, nil
|
|
}
|
|
}
|
|
|
|
var product struct {
|
|
ID string `json:"id"`
|
|
}
|
|
if err := c.post(ctx, "/products", url.Values{"name": {"enkly " + info.Name}}, &product); err != nil {
|
|
return "", err
|
|
}
|
|
var price struct {
|
|
ID string `json:"id"`
|
|
}
|
|
err := c.post(ctx, "/prices", url.Values{
|
|
"product": {product.ID},
|
|
"currency": {"eur"},
|
|
"unit_amount": {strconv.Itoa(info.PriceEUR * 100)},
|
|
"recurring[interval]": {"month"},
|
|
"nickname": {"enkly " + info.Name + " (monthly)"},
|
|
"lookup_key": {lookupKey},
|
|
"transfer_lookup_key": {"true"},
|
|
}, &price)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return price.ID, nil
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Checkout Sessions
|
|
// ---------------------------------------------------------------------
|
|
|
|
// CheckoutParams describes one subscription checkout.
|
|
type CheckoutParams struct {
|
|
PriceID string
|
|
CustomerID string // existing Stripe customer, if any (preferred over CustomerEmail)
|
|
CustomerEmail string // used only when CustomerID is empty
|
|
ClientReferenceID string // enkly user ID, as a string — echoed back on the session object
|
|
UserID int64
|
|
SuccessURL string
|
|
CancelURL string
|
|
}
|
|
|
|
// CreateCheckoutSession starts a Stripe-hosted Checkout page for a new
|
|
// subscription. The caller redirects the browser to the returned URL;
|
|
// enkly never collects card details itself.
|
|
func (c *Client) CreateCheckoutSession(ctx context.Context, p CheckoutParams) (sessionID, checkoutURL string, err error) {
|
|
form := url.Values{
|
|
"mode": {"subscription"},
|
|
"success_url": {p.SuccessURL},
|
|
"cancel_url": {p.CancelURL},
|
|
"line_items[0][price]": {p.PriceID},
|
|
"line_items[0][quantity]": {"1"},
|
|
"client_reference_id": {p.ClientReferenceID},
|
|
"subscription_data[metadata][user_id]": {strconv.FormatInt(p.UserID, 10)},
|
|
"allow_promotion_codes": {"true"},
|
|
"billing_address_collection": {"auto"},
|
|
}
|
|
if p.CustomerID != "" {
|
|
form.Set("customer", p.CustomerID)
|
|
} else if p.CustomerEmail != "" {
|
|
form.Set("customer_email", p.CustomerEmail)
|
|
}
|
|
|
|
var out struct {
|
|
ID string `json:"id"`
|
|
URL string `json:"url"`
|
|
}
|
|
if err := c.post(ctx, "/checkout/sessions", form, &out); err != nil {
|
|
return "", "", err
|
|
}
|
|
return out.ID, out.URL, nil
|
|
}
|
|
|
|
// CheckoutSession is the subset of a Checkout Session object handlers
|
|
// need after a "checkout.session.completed" webhook.
|
|
type CheckoutSession struct {
|
|
ID string `json:"id"`
|
|
Customer string `json:"customer"`
|
|
Subscription string `json:"subscription"`
|
|
ClientReferenceID string `json:"client_reference_id"`
|
|
Metadata map[string]string `json:"metadata"`
|
|
}
|
|
|
|
// GetCheckoutSession retrieves a Checkout Session by ID. The success page
|
|
// uses it to confirm a payment with Stripe directly, so a plan activates
|
|
// even when webhooks aren't reaching the server (e.g. local testing).
|
|
func (c *Client) GetCheckoutSession(ctx context.Context, id string) (*CheckoutSession, error) {
|
|
var out CheckoutSession
|
|
if err := c.get(ctx, "/checkout/sessions/"+url.PathEscape(id), &out); err != nil {
|
|
return nil, err
|
|
}
|
|
return &out, nil
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Billing Portal
|
|
// ---------------------------------------------------------------------
|
|
|
|
// CreateBillingPortalSession opens Stripe's hosted "manage my
|
|
// subscription" page (update card, change plan, cancel, view invoices)
|
|
// for an existing customer.
|
|
func (c *Client) CreateBillingPortalSession(ctx context.Context, customerID, returnURL string) (string, error) {
|
|
form := url.Values{"customer": {customerID}, "return_url": {returnURL}}
|
|
var out struct {
|
|
URL string `json:"url"`
|
|
}
|
|
if err := c.post(ctx, "/billing_portal/sessions", form, &out); err != nil {
|
|
return "", err
|
|
}
|
|
return out.URL, nil
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Subscriptions
|
|
// ---------------------------------------------------------------------
|
|
|
|
// StripeSubscription is the subset of a Subscription object handlers
|
|
// need to keep internal/db's subscriptions table in sync.
|
|
type StripeSubscription struct {
|
|
ID string `json:"id"`
|
|
Customer string `json:"customer"`
|
|
Status string `json:"status"`
|
|
CancelAtPeriodEnd bool `json:"cancel_at_period_end"`
|
|
CurrentPeriodEnd int64 `json:"current_period_end"` // unix seconds; 0 on API versions that moved this onto items
|
|
Metadata map[string]string `json:"metadata"`
|
|
Items struct {
|
|
Data []struct {
|
|
Price struct {
|
|
ID string `json:"id"`
|
|
} `json:"price"`
|
|
CurrentPeriodEnd int64 `json:"current_period_end"`
|
|
} `json:"data"`
|
|
} `json:"items"`
|
|
}
|
|
|
|
// PriceID returns the subscription's single price ID (enkly's checkout
|
|
// always creates a one-line-item subscription), or "" if absent.
|
|
func (s StripeSubscription) PriceID() string {
|
|
if len(s.Items.Data) == 0 {
|
|
return ""
|
|
}
|
|
return s.Items.Data[0].Price.ID
|
|
}
|
|
|
|
// PeriodEndUnix returns current_period_end, preferring the
|
|
// subscription-level field and falling back to the first item's (newer
|
|
// Stripe API versions report it per-item instead).
|
|
func (s StripeSubscription) PeriodEndUnix() int64 {
|
|
if s.CurrentPeriodEnd != 0 {
|
|
return s.CurrentPeriodEnd
|
|
}
|
|
if len(s.Items.Data) > 0 {
|
|
return s.Items.Data[0].CurrentPeriodEnd
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// GetSubscription retrieves a subscription by ID — used as a fallback
|
|
// when a webhook event's embedded object is missing a field this code
|
|
// needs (e.g. an older API version, or a checkout.session.completed
|
|
// event that only references the subscription by ID).
|
|
func (c *Client) GetSubscription(ctx context.Context, id string) (*StripeSubscription, error) {
|
|
var out StripeSubscription
|
|
if err := c.get(ctx, "/subscriptions/"+url.PathEscape(id), &out); err != nil {
|
|
return nil, err
|
|
}
|
|
return &out, nil
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Webhooks
|
|
// ---------------------------------------------------------------------
|
|
|
|
// Event is the minimal shape of a Stripe webhook event enkly needs:
|
|
// enough to dispatch on Type and unmarshal Data.Object into the
|
|
// concrete struct (CheckoutSession / StripeSubscription) each handler
|
|
// expects.
|
|
type Event struct {
|
|
ID string `json:"id"`
|
|
Type string `json:"type"`
|
|
Data struct {
|
|
Object json.RawMessage `json:"object"`
|
|
} `json:"data"`
|
|
}
|
|
|
|
// maxWebhookSkew rejects a webhook whose signed timestamp is further
|
|
// than this from "now" — Stripe's own recommended replay-attack guard.
|
|
const maxWebhookSkew = 5 * time.Minute
|
|
|
|
// ConstructEvent verifies a webhook payload's "Stripe-Signature" header
|
|
// against webhookSecret and, only if it's genuine, parses it into an
|
|
// Event. This reimplements Stripe's documented signature scheme
|
|
// (HMAC-SHA256 over "{timestamp}.{payload}", header format
|
|
// "t=…,v1=…,v1=…") by hand rather than pulling in the SDK just for
|
|
// this one function.
|
|
func ConstructEvent(payload []byte, sigHeader, webhookSecret string) (*Event, error) {
|
|
if webhookSecret == "" {
|
|
return nil, errors.New("billing: STRIPE_WEBHOOK_SECRET is not configured")
|
|
}
|
|
ts, sigs, err := parseSignatureHeader(sigHeader)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if skew := time.Since(time.Unix(ts, 0)); skew > maxWebhookSkew || skew < -maxWebhookSkew {
|
|
return nil, fmt.Errorf("billing: webhook timestamp %s outside tolerance", time.Unix(ts, 0))
|
|
}
|
|
|
|
mac := hmac.New(sha256.New, []byte(webhookSecret))
|
|
mac.Write([]byte(strconv.FormatInt(ts, 10)))
|
|
mac.Write([]byte("."))
|
|
mac.Write(payload)
|
|
want := mac.Sum(nil)
|
|
|
|
ok := false
|
|
for _, sig := range sigs {
|
|
got, err := hex.DecodeString(sig)
|
|
if err == nil && hmac.Equal(got, want) {
|
|
ok = true
|
|
break
|
|
}
|
|
}
|
|
if !ok {
|
|
return nil, errors.New("billing: webhook signature verification failed")
|
|
}
|
|
|
|
var ev Event
|
|
if err := json.Unmarshal(payload, &ev); err != nil {
|
|
return nil, fmt.Errorf("billing: decode webhook payload: %w", err)
|
|
}
|
|
return &ev, nil
|
|
}
|
|
|
|
// parseSignatureHeader splits "t=1614556800,v1=abc…,v1=def…" (Stripe
|
|
// includes a second v1 briefly while rotating a webhook signing
|
|
// secret) into the timestamp and the list of v1 signatures to try.
|
|
func parseSignatureHeader(header string) (ts int64, sigs []string, err error) {
|
|
for _, part := range strings.Split(header, ",") {
|
|
k, v, ok := strings.Cut(part, "=")
|
|
if !ok {
|
|
continue
|
|
}
|
|
switch strings.TrimSpace(k) {
|
|
case "t":
|
|
ts, err = strconv.ParseInt(strings.TrimSpace(v), 10, 64)
|
|
if err != nil {
|
|
return 0, nil, fmt.Errorf("billing: bad webhook timestamp: %w", err)
|
|
}
|
|
case "v1":
|
|
sigs = append(sigs, strings.TrimSpace(v))
|
|
}
|
|
}
|
|
if ts == 0 || len(sigs) == 0 {
|
|
return 0, nil, errors.New("billing: malformed Stripe-Signature header")
|
|
}
|
|
return ts, sigs, nil
|
|
}
|