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.
This commit is contained in:
Ruidy 2025-09-27 11:32:34 +02:00
parent 17db8bb165
commit 7e1e6e1df9
No known key found for this signature in database
GPG key ID: 705C24D202990805
8 changed files with 435 additions and 12 deletions

2
go.mod
View file

@ -1,3 +1,5 @@
module github.com/rjNemo/payit module github.com/rjNemo/payit
go 1.25.1 go 1.25.1
require github.com/stripe/stripe-go/v78 v78.12.0

20
go.sum Normal file
View file

@ -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=

67
internal/stripe/client.go Normal file
View file

@ -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
}

View file

@ -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",
}
}

12
internal/stripe/types.go Normal file
View file

@ -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"`
}

52
internal/web/handlers.go Normal file
View file

@ -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
}
}

View file

@ -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)
}
}

View file

@ -1,28 +1,31 @@
package web package web
import ( import (
"context"
"net/http" "net/http"
stripe "github.com/rjNemo/payit/internal/stripe"
"github.com/rjNemo/payit/config" "github.com/rjNemo/payit/config"
) )
// Handler aggregates dependencies required by HTTP handlers. type checkoutService interface {
type Handler struct { CreateSession(context.Context, stripe.CheckoutSessionRequest) (stripe.CheckoutSessionResult, error)
cfg config.Config
} }
// NewServer constructs the root HTTP handler. The initial implementation only // Handler aggregates dependencies required by HTTP handlers.
// exposes placeholder routes; later phases will wire Stripe-backed handlers and type Handler struct {
// templates. 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 { 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 := http.NewServeMux()
mux.HandleFunc("/", h.notImplemented) mux.HandleFunc("/api/checkout", h.createCheckoutSession)
return mux return mux
} }
func (h *Handler) notImplemented(w http.ResponseWriter, r *http.Request) {
http.Error(w, "PayIt demo coming soon", http.StatusNotImplemented)
}