package stripeapi import ( "context" "encoding/json" "errors" "net/http" "net/http/httptest" "net/url" "testing" "time" ) // A subscription reads the same in the API version abono pins (period // on the items) and an older one's webhook (on the subscription), with // its customer expanded or not. func TestSubscriptionWire(t *testing.T) { t.Parallel() for name, raw := range map[string]string{ "basil, expanded": `{"id":"sub_1","status":"active","customer":{"id":"cus_1","email":"a@example.com"},"items":{"data":[{"current_period_start":1700000000,"current_period_end":1800000000,"price":{"id":"price_A","recurring":{"interval":"year","interval_count":2}}}]}}`, "older, id only": `{"id":"sub_1","status":"active","customer":"cus_1","current_period_start":1700000000,"current_period_end":1800000000,"items":{"data":[{"price":{"id":"price_A","recurring":{"interval":"year","interval_count":2}}}]}}`, } { var w subscriptionWire if err := json.Unmarshal([]byte(raw), &w); err != nil { t.Fatal(err) } s, err := w.decode() if err != nil { t.Fatal(err) } price, interval := s.PriceID() if _, count := s.Recurring(); count != 2 { t.Errorf("%s: interval_count %d, want 2", name, count) } if s.Customer.ID != "cus_1" || price != "price_A" || interval != "year" || !s.PeriodEnd().Equal(time.Unix(1_800_000_000, 0)) || !s.PeriodStart().Equal(time.Unix(1_700_000_000, 0)) { t.Errorf("%s: %+v", name, s) } } // Items first (ruling R21): a top-level start, later or not, is read // only when no item has one, and of several items the earliest wins. for name, c := range map[string]struct { raw string want int64 }{ "top-level later than the item": {`{"id":"sub_1","current_period_start":1750000000,"items":{"data":[{"current_period_start":1700000000}]}}`, 1_700_000_000}, "top-level earlier than the item": {`{"id":"sub_1","current_period_start":1650000000,"items":{"data":[{"current_period_start":1700000000}]}}`, 1_700_000_000}, "items disagree": {`{"id":"sub_1","items":{"data":[{"current_period_start":1710000000},{"current_period_start":1700000000}]}}`, 1_700_000_000}, "an item without one": {`{"id":"sub_1","current_period_start":1750000000,"items":{"data":[{},{"current_period_start":1700000000}]}}`, 1_700_000_000}, "only top-level": {`{"id":"sub_1","current_period_start":1750000000,"items":{"data":[{}]}}`, 1_750_000_000}, } { var w subscriptionWire if err := json.Unmarshal([]byte(c.raw), &w); err != nil { t.Fatal(err) } s, err := w.decode() if err != nil { t.Fatal(err) } if got := s.PeriodStart(); !got.Equal(time.Unix(c.want, 0)) { t.Errorf("%s: PeriodStart = %v, want %v", name, got, time.Unix(c.want, 0).UTC()) } } var none Subscription if !none.PeriodStart().IsZero() || !none.PeriodEnd().IsZero() { t.Error("a subscription with no period has a start or an end") } } // fake serves h and returns a Client pointed at it with key "k". func fake(t *testing.T, h http.HandlerFunc) *Client { t.Helper() srv := httptest.NewServer(h) t.Cleanup(srv.Close) return &Client{Key: "k", Base: srv.URL} } func TestCallSendsPinnedVersionAndKey(t *testing.T) { t.Parallel() var version, auth string c := fake(t, func(w http.ResponseWriter, r *http.Request) { version, auth = r.Header.Get("Stripe-Version"), r.Header.Get("Authorization") w.Write([]byte(`{}`)) }) if _, err := c.GetCheckout(context.Background(), "cs_1"); err != nil { t.Fatal(err) } if version != "2025-03-31.basil" || auth != "Bearer k" { t.Errorf("Stripe-Version %q, Authorization %q", version, auth) } } func TestCallSendsIdempotencyKeyOnlyWhenGiven(t *testing.T) { t.Parallel() var got []string var present []bool c := fake(t, func(w http.ResponseWriter, r *http.Request) { _, ok := r.Header["Idempotency-Key"] present = append(present, ok) got = append(got, r.Header.Get("Idempotency-Key")) w.Write([]byte(`{}`)) }) var out struct{} if err := c.call(context.Background(), http.MethodPost, "/v1/x", url.Values{"a": {"b"}}, "", &out); err != nil { t.Fatal(err) } if err := c.call(context.Background(), http.MethodPost, "/v1/x", url.Values{"a": {"b"}}, "idem-1", &out); err != nil { t.Fatal(err) } if len(got) != 2 || present[0] || !present[1] || got[1] != "idem-1" { t.Errorf("keys %q, present %v", got, present) } } func TestStripeErrorDecodes(t *testing.T) { t.Parallel() c := fake(t, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusPaymentRequired) w.Write([]byte(`{"error":{"type":"card_error","code":"card_declined","message":"Your card was declined."}}`)) }) _, err := c.GetCheckout(context.Background(), "cs_1") var e *Error if !errors.As(err, &e) { t.Fatalf("err %T %v, want *Error", err, err) } if *e != (Error{Status: 402, Type: "card_error", Code: "card_declined", Message: "Your card was declined."}) { t.Errorf("%+v", *e) } if NotFound(err) { t.Error("402 reported as not found") } } func TestNotFound(t *testing.T) { t.Parallel() c := fake(t, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) w.Write([]byte(`{"error":{"type":"invalid_request_error","code":"resource_missing","message":"No such subscription"}}`)) }) _, err := c.GetSubscription(context.Background(), "sub_x") if !NotFound(err) { t.Errorf("not found not recognised: %v", err) } } func TestListSubscriptionsPages(t *testing.T) { t.Parallel() var afters []string c := fake(t, func(w http.ResponseWriter, r *http.Request) { after := r.URL.Query().Get("starting_after") afters = append(afters, after) if after == "" { w.Write([]byte(`{"has_more":true,"data":[{"id":"sub_1","customer":"cus_1"},{"id":"sub_2","customer":"cus_2"}]}`)) return } w.Write([]byte(`{"has_more":false,"data":[{"id":"sub_3","customer":{"id":"cus_3","email":"c@example.com"}}]}`)) }) var ids, custs []string err := c.ListSubscriptions(context.Background(), func(s Subscription) error { ids, custs = append(ids, s.ID), append(custs, s.Customer.ID) return nil }) if err != nil { t.Fatal(err) } if len(ids) != 3 || ids[0] != "sub_1" || ids[2] != "sub_3" || custs[2] != "cus_3" { t.Errorf("ids %v customers %v", ids, custs) } if len(afters) != 2 || afters[0] != "" || afters[1] != "sub_2" { t.Errorf("starting_after %q, want [\"\" \"sub_2\"]", afters) } } func TestGetPrice(t *testing.T) { t.Parallel() var path string c := fake(t, func(w http.ResponseWriter, r *http.Request) { path = r.URL.Path w.Write([]byte(`{"id":"price_A","unit_amount":4900,"currency":"eur","recurring":{"interval":"month","interval_count":12}}`)) }) p, err := c.GetPrice(context.Background(), "price_A") if err != nil { t.Fatal(err) } // Every twelve months is not monthly (final review I7): the count is kept. if path != "/v1/prices/price_A" || p.ID != "price_A" || p.UnitAmount != 4900 || p.Currency != "eur" || p.Recurring.Interval != "month" || p.Recurring.IntervalCount != 12 { t.Errorf("%s %+v", path, p) } } func TestCreateCheckoutAndPortalAndPromotion(t *testing.T) { t.Parallel() c := fake(t, func(w http.ResponseWriter, r *http.Request) { r.ParseForm() switch r.URL.Path { case "/v1/checkout/sessions": if r.Method != http.MethodPost || r.PostForm.Get("mode") != "subscription" { t.Errorf("checkout: %s %v", r.Method, r.PostForm) } w.Write([]byte(`{"id":"cs_1","url":"https://checkout.example/cs_1"}`)) case "/v1/billing_portal/sessions": if r.PostForm.Get("customer") != "cus_1" || r.PostForm.Get("return_url") != "https://x/back" { t.Errorf("portal: %v", r.PostForm) } w.Write([]byte(`{"url":"https://portal.example/p"}`)) case "/v1/promotion_codes": if r.URL.Query().Get("code") == "NONE" { w.Write([]byte(`{"data":[]}`)) return } w.Write([]byte(`{"data":[{"id":"promo_1"}]}`)) default: t.Errorf("unexpected %s", r.URL.Path) } }) ctx := context.Background() cs, err := c.CreateCheckout(ctx, url.Values{"mode": {"subscription"}}) if err != nil || cs.ID != "cs_1" || cs.URL != "https://checkout.example/cs_1" { t.Errorf("checkout %+v %v", cs, err) } if u, err := c.PortalURL(ctx, "cus_1", "https://x/back"); err != nil || u != "https://portal.example/p" { t.Errorf("portal %q %v", u, err) } if id, err := c.PromotionCode(ctx, "INNOVATE"); err != nil || id != "promo_1" { t.Errorf("promo %q %v", id, err) } if id, err := c.PromotionCode(ctx, "NONE"); err != nil || id != "" { t.Errorf("no promo %q %v", id, err) } }