From 7e1e6e1df903bc9d94d59119c6e035956eea3bb5 Mon Sep 17 00:00:00 2001 From: Ruidy Date: Sat, 27 Sep 2025 11:32:34 +0200 Subject: [PATCH] feat(stripe): add Stripe Checkout session API and tests - Integrate Stripe Go SDK and implement a checkout service for creating Stripe Checkout sessions for a demo product. - Add request/response types for checkout sessions. - Create HTTP handler for /api/checkout to initiate sessions via POST. - Provide comprehensive unit tests for Stripe client and web handler. - Wire Stripe-backed endpoint in server setup. --- go.mod | 2 + go.sum | 20 +++++ internal/stripe/client.go | 67 ++++++++++++++++ internal/stripe/client_test.go | 142 +++++++++++++++++++++++++++++++++ internal/stripe/types.go | 12 +++ internal/web/handlers.go | 52 ++++++++++++ internal/web/handlers_test.go | 125 +++++++++++++++++++++++++++++ internal/web/server.go | 27 ++++--- 8 files changed, 435 insertions(+), 12 deletions(-) create mode 100644 go.sum create mode 100644 internal/stripe/client.go create mode 100644 internal/stripe/client_test.go create mode 100644 internal/stripe/types.go create mode 100644 internal/web/handlers.go create mode 100644 internal/web/handlers_test.go diff --git a/go.mod b/go.mod index e491377..7dddab1 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,5 @@ module github.com/rjNemo/payit go 1.25.1 + +require github.com/stripe/stripe-go/v78 v78.12.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..36936bd --- /dev/null +++ b/go.sum @@ -0,0 +1,20 @@ +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stripe/stripe-go/v78 v78.12.0 h1:YzKjO5Cx1dTfSkqBXzg6GFG7LnRHkZiU0+k0vSF5yt4= +github.com/stripe/stripe-go/v78 v78.12.0/go.mod h1:GjncxVLUc1xoIOidFqVwq+y3pYiG7JLVWiVQxTsLrvQ= +golang.org/x/net v0.0.0-20210520170846-37e1c6afe023 h1:ADo5wSpq2gqaCGQWzk7S5vd//0iyyLeAratkEoG5dLE= +golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/stripe/client.go b/internal/stripe/client.go new file mode 100644 index 0000000..1d4495e --- /dev/null +++ b/internal/stripe/client.go @@ -0,0 +1,67 @@ +package stripe + +import ( + "context" + "errors" + + "github.com/rjNemo/payit/config" + stripe "github.com/stripe/stripe-go/v78" + "github.com/stripe/stripe-go/v78/client" +) + +type sessionCreator interface { + New(params *stripe.CheckoutSessionParams) (*stripe.CheckoutSession, error) +} + +// Service coordinates Stripe Checkout session creation for the demo product. +type Service struct { + product config.ProductConfig + sessions sessionCreator +} + +// NewService instantiates a Stripe-backed checkout service using the provided API key. +func NewService(apiKey string, product config.ProductConfig) *Service { + stripeClient := client.New(apiKey, nil) + + return &Service{ + product: product, + sessions: stripeClient.CheckoutSessions, + } +} + +// CreateSession creates a Stripe Checkout session for the configured demo product. +func (s *Service) CreateSession(ctx context.Context, req CheckoutSessionRequest) (CheckoutSessionResult, error) { + quantity := req.Quantity + if quantity <= 0 { + quantity = 1 + } + + params := &stripe.CheckoutSessionParams{} + params.Context = ctx + params.SuccessURL = stripe.String(s.product.SuccessURL) + params.CancelURL = stripe.String(s.product.CancelURL) + params.Mode = stripe.String(string(stripe.CheckoutSessionModePayment)) + params.PaymentMethodTypes = stripe.StringSlice([]string{"card"}) + + params.LineItems = append(params.LineItems, &stripe.CheckoutSessionLineItemParams{ + Quantity: stripe.Int64(quantity), + PriceData: &stripe.CheckoutSessionLineItemPriceDataParams{ + Currency: stripe.String(s.product.Currency), + UnitAmount: stripe.Int64(s.product.PriceCents), + ProductData: &stripe.CheckoutSessionLineItemPriceDataProductDataParams{ + Name: stripe.String(s.product.Name), + Description: stripe.String(s.product.Description), + }, + }, + }) + + session, err := s.sessions.New(params) + if err != nil { + return CheckoutSessionResult{}, err + } + if session == nil { + return CheckoutSessionResult{}, errors.New("stripe returned nil session") + } + + return CheckoutSessionResult{ID: session.ID, URL: session.URL}, nil +} diff --git a/internal/stripe/client_test.go b/internal/stripe/client_test.go new file mode 100644 index 0000000..ebbede0 --- /dev/null +++ b/internal/stripe/client_test.go @@ -0,0 +1,142 @@ +package stripe + +import ( + "context" + "errors" + "testing" + + "github.com/rjNemo/payit/config" + stripeapi "github.com/stripe/stripe-go/v78" +) + +type fakeSessionCreator struct { + lastParams *stripeapi.CheckoutSessionParams + result *stripeapi.CheckoutSession + err error +} + +func (f *fakeSessionCreator) New(params *stripeapi.CheckoutSessionParams) (*stripeapi.CheckoutSession, error) { + f.lastParams = params + return f.result, f.err +} + +func TestService_CreateSessionSuccess(t *testing.T) { + product := testProductConfig() + fake := &fakeSessionCreator{ + result: &stripeapi.CheckoutSession{ + ID: "cs_test_123", + URL: "https://stripe.test/checkout", + }, + } + + svc := &Service{product: product, sessions: fake} + + res, err := svc.CreateSession(context.Background(), CheckoutSessionRequest{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if res.ID != "cs_test_123" || res.URL != "https://stripe.test/checkout" { + t.Fatalf("unexpected result: %#v", res) + } + + params := fake.lastParams + if params == nil { + t.Fatal("expected params to be captured") + } + if params.Context == nil { + t.Fatal("expected context to propagate") + } + if params.Mode == nil || *params.Mode != string(stripeapi.CheckoutSessionModePayment) { + t.Fatalf("unexpected mode: %v", params.Mode) + } + if len(params.PaymentMethodTypes) != 1 || params.PaymentMethodTypes[0] == nil || *params.PaymentMethodTypes[0] != "card" { + t.Fatalf("unexpected payment methods: %#v", params.PaymentMethodTypes) + } + + if len(params.LineItems) != 1 { + t.Fatalf("expected one line item, got %d", len(params.LineItems)) + } + + item := params.LineItems[0] + if item.Quantity == nil || *item.Quantity != 1 { + t.Fatalf("unexpected quantity: %v", item.Quantity) + } + if item.PriceData == nil { + t.Fatal("expected price data to be set") + } + if item.PriceData.UnitAmount == nil || *item.PriceData.UnitAmount != product.PriceCents { + t.Fatalf("unexpected unit amount: %v", item.PriceData.UnitAmount) + } + if item.PriceData.Currency == nil || *item.PriceData.Currency != product.Currency { + t.Fatalf("unexpected currency: %v", item.PriceData.Currency) + } + if item.PriceData.ProductData == nil { + t.Fatal("expected product data") + } + if item.PriceData.ProductData.Name == nil || *item.PriceData.ProductData.Name != product.Name { + t.Fatalf("unexpected product name: %v", item.PriceData.ProductData.Name) + } + if item.PriceData.ProductData.Description == nil || *item.PriceData.ProductData.Description != product.Description { + t.Fatalf("unexpected product description: %v", item.PriceData.ProductData.Description) + } +} + +func TestService_CreateSessionWithCustomQuantity(t *testing.T) { + product := testProductConfig() + fake := &fakeSessionCreator{ + result: &stripeapi.CheckoutSession{}, + } + + svc := &Service{product: product, sessions: fake} + + _, err := svc.CreateSession(context.Background(), CheckoutSessionRequest{Quantity: 3}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + params := fake.lastParams + if params == nil || len(params.LineItems) != 1 { + t.Fatalf("expected line item to be set: %#v", params) + } + + qty := params.LineItems[0].Quantity + if qty == nil || *qty != 3 { + t.Fatalf("unexpected quantity: %v", qty) + } +} + +func TestService_CreateSessionError(t *testing.T) { + product := testProductConfig() + fake := &fakeSessionCreator{err: errors.New("boom")} + + svc := &Service{product: product, sessions: fake} + + _, err := svc.CreateSession(context.Background(), CheckoutSessionRequest{}) + if err == nil { + t.Fatal("expected error") + } +} + +func TestService_CreateSessionNilSession(t *testing.T) { + product := testProductConfig() + fake := &fakeSessionCreator{} + + svc := &Service{product: product, sessions: fake} + + _, err := svc.CreateSession(context.Background(), CheckoutSessionRequest{}) + if err == nil { + t.Fatal("expected error for nil session") + } +} + +func testProductConfig() config.ProductConfig { + return config.ProductConfig{ + Name: "Demo Widget", + Description: "A very cool widget", + PriceCents: 1999, + Currency: "usd", + SuccessURL: "https://example.com/success", + CancelURL: "https://example.com/cancel", + } +} diff --git a/internal/stripe/types.go b/internal/stripe/types.go new file mode 100644 index 0000000..411d072 --- /dev/null +++ b/internal/stripe/types.go @@ -0,0 +1,12 @@ +package stripe + +// CheckoutSessionRequest captures optional inputs for creating a checkout session. +type CheckoutSessionRequest struct { + Quantity int64 `json:"quantity"` +} + +// CheckoutSessionResult contains the data returned to callers initiating checkout. +type CheckoutSessionResult struct { + ID string `json:"id"` + URL string `json:"url"` +} diff --git a/internal/web/handlers.go b/internal/web/handlers.go new file mode 100644 index 0000000..73bca54 --- /dev/null +++ b/internal/web/handlers.go @@ -0,0 +1,52 @@ +package web + +import ( + "encoding/json" + "errors" + "io" + "net/http" + + stripe "github.com/rjNemo/payit/internal/stripe" +) + +func (h *Handler) createCheckoutSession(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.Header().Set("Allow", http.MethodPost) + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + defer r.Body.Close() + + var req stripe.CheckoutSessionRequest + if r.Body != nil { + dec := json.NewDecoder(r.Body) + dec.DisallowUnknownFields() + + if err := dec.Decode(&req); err != nil { + if errors.Is(err, io.EOF) { + // Empty body is acceptable; default quantity applies. + } else { + http.Error(w, "invalid request payload", http.StatusBadRequest) + return + } + } + + if dec.More() { + http.Error(w, "unexpected data in request body", http.StatusBadRequest) + return + } + } + + session, err := h.checkout.CreateSession(r.Context(), req) + if err != nil { + http.Error(w, "checkout session failed", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(session); err != nil { + http.Error(w, "failed to encode response", http.StatusInternalServerError) + return + } +} diff --git a/internal/web/handlers_test.go b/internal/web/handlers_test.go new file mode 100644 index 0000000..3ba3318 --- /dev/null +++ b/internal/web/handlers_test.go @@ -0,0 +1,125 @@ +package web + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + + stripe "github.com/rjNemo/payit/internal/stripe" +) + +type fakeCheckoutService struct { + result stripe.CheckoutSessionResult + err error + req stripe.CheckoutSessionRequest +} + +func (f *fakeCheckoutService) CreateSession(ctx context.Context, req stripe.CheckoutSessionRequest) (stripe.CheckoutSessionResult, error) { + f.req = req + if f.err != nil { + return stripe.CheckoutSessionResult{}, f.err + } + return f.result, nil +} + +func TestCreateCheckoutSessionSuccess(t *testing.T) { + handler := &Handler{ + checkout: &fakeCheckoutService{ + result: stripe.CheckoutSessionResult{ID: "cs_test_1", URL: "https://stripe.test/checkout"}, + }, + } + + body, _ := json.Marshal(map[string]int{"quantity": 2}) + req := httptest.NewRequest(http.MethodPost, "/api/checkout", bytes.NewReader(body)) + rec := httptest.NewRecorder() + + handler.createCheckoutSession(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", rec.Code) + } + + if ct := rec.Header().Get("Content-Type"); ct != "application/json" { + t.Fatalf("expected json content type, got %s", ct) + } + + var payload stripe.CheckoutSessionResult + if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { + t.Fatalf("expected valid json response: %v", err) + } + if payload.ID != "cs_test_1" || payload.URL == "" { + t.Fatalf("unexpected payload: %#v", payload) + } + + svc := handler.checkout.(*fakeCheckoutService) + if svc.req.Quantity != 2 { + t.Fatalf("expected quantity 2, got %d", svc.req.Quantity) + } +} + +func TestCreateCheckoutSessionDefaultsQuantity(t *testing.T) { + fakeSvc := &fakeCheckoutService{ + result: stripe.CheckoutSessionResult{ID: "cs_test_1"}, + } + handler := &Handler{checkout: fakeSvc} + + req := httptest.NewRequest(http.MethodPost, "/api/checkout", http.NoBody) + rec := httptest.NewRecorder() + + handler.createCheckoutSession(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", rec.Code) + } + if fakeSvc.req.Quantity != 0 { + t.Fatalf("expected zero quantity in request, got %d", fakeSvc.req.Quantity) + } +} + +func TestCreateCheckoutSessionRejectsInvalidJSON(t *testing.T) { + handler := &Handler{checkout: &fakeCheckoutService{}} + + req := httptest.NewRequest(http.MethodPost, "/api/checkout", bytes.NewBufferString("{")) + rec := httptest.NewRecorder() + + handler.createCheckoutSession(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected status 400, got %d", rec.Code) + } +} + +func TestCreateCheckoutSessionStripeFailure(t *testing.T) { + handler := &Handler{ + checkout: &fakeCheckoutService{err: errors.New("stripe failure")}, + } + + req := httptest.NewRequest(http.MethodPost, "/api/checkout", http.NoBody) + rec := httptest.NewRecorder() + + handler.createCheckoutSession(rec, req) + + if rec.Code != http.StatusInternalServerError { + t.Fatalf("expected status 500, got %d", rec.Code) + } +} + +func TestCreateCheckoutSessionMethodNotAllowed(t *testing.T) { + handler := &Handler{checkout: &fakeCheckoutService{}} + + req := httptest.NewRequest(http.MethodGet, "/api/checkout", http.NoBody) + rec := httptest.NewRecorder() + + handler.createCheckoutSession(rec, req) + + if rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("expected status 405, got %d", rec.Code) + } + if allow := rec.Header().Get("Allow"); allow != http.MethodPost { + t.Fatalf("expected Allow header to be POST, got %s", allow) + } +} diff --git a/internal/web/server.go b/internal/web/server.go index e7eacd4..d4d4d94 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -1,28 +1,31 @@ package web import ( + "context" "net/http" + stripe "github.com/rjNemo/payit/internal/stripe" + "github.com/rjNemo/payit/config" ) -// Handler aggregates dependencies required by HTTP handlers. -type Handler struct { - cfg config.Config +type checkoutService interface { + CreateSession(context.Context, stripe.CheckoutSessionRequest) (stripe.CheckoutSessionResult, error) } -// NewServer constructs the root HTTP handler. The initial implementation only -// exposes placeholder routes; later phases will wire Stripe-backed handlers and -// templates. +// Handler aggregates dependencies required by HTTP handlers. +type Handler struct { + cfg config.Config + checkout checkoutService +} + +// NewServer constructs the root HTTP handler, wiring Stripe-backed endpoints as they are implemented. func NewServer(cfg config.Config) http.Handler { - h := &Handler{cfg: cfg} + checkoutSvc := stripe.NewService(cfg.StripeSecretKey, cfg.Product) + h := &Handler{cfg: cfg, checkout: checkoutSvc} mux := http.NewServeMux() - mux.HandleFunc("/", h.notImplemented) + mux.HandleFunc("/api/checkout", h.createCheckoutSession) return mux } - -func (h *Handler) notImplemented(w http.ResponseWriter, r *http.Request) { - http.Error(w, "PayIt demo coming soon", http.StatusNotImplemented) -}