mirror of
https://github.com/rjNemo/payit
synced 2026-06-06 02:16:40 +00:00
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:
parent
17db8bb165
commit
7e1e6e1df9
8 changed files with 435 additions and 12 deletions
2
go.mod
2
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
|
||||
|
|
|
|||
20
go.sum
Normal file
20
go.sum
Normal 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
67
internal/stripe/client.go
Normal 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
|
||||
}
|
||||
142
internal/stripe/client_test.go
Normal file
142
internal/stripe/client_test.go
Normal 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
12
internal/stripe/types.go
Normal 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
52
internal/web/handlers.go
Normal 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
|
||||
}
|
||||
}
|
||||
125
internal/web/handlers_test.go
Normal file
125
internal/web/handlers_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue