{{ .ProductName }}
+{{ .ProductDescription }}
+diff --git a/.gitignore b/.gitignore index a0b127d..60ad5ff 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ *cache +*env* diff --git a/config/config.go b/config/config.go index aae84b2..0bbc2c9 100644 --- a/config/config.go +++ b/config/config.go @@ -97,7 +97,9 @@ func applyEnvFile(path string) (retErr error) { key := strings.TrimSpace(parts[0]) value := strings.TrimSpace(parts[1]) - _ = os.Setenv(key, value) + if _, exists := os.LookupEnv(key); !exists { + _ = os.Setenv(key, value) + } } if err := scanner.Err(); err != nil { diff --git a/internal/payments/driver/stripe/checkout.go b/internal/payments/driver/stripe/checkout.go new file mode 100644 index 0000000..c4eb546 --- /dev/null +++ b/internal/payments/driver/stripe/checkout.go @@ -0,0 +1,69 @@ +package stripe + +import ( + "context" + "errors" + + stripesdk "github.com/stripe/stripe-go/v78" + "github.com/stripe/stripe-go/v78/client" + + "github.com/rjNemo/payit/config" + "github.com/rjNemo/payit/internal/payments" +) + +type sessionCreator interface { + New(params *stripesdk.CheckoutSessionParams) (*stripesdk.CheckoutSession, error) +} + +// Driver implements the CheckoutDriver interface using the Stripe SDK. +type Driver struct { + product config.ProductConfig + sessions sessionCreator +} + +// NewDriver creates a Stripe-backed checkout driver with the provided credentials. +func NewDriver(apiKey string, product config.ProductConfig) *Driver { + stripeClient := client.New(apiKey, nil) + + return &Driver{ + product: product, + sessions: stripeClient.CheckoutSessions, + } +} + +// CreateSession delegates session creation to Stripe, translating domain values to SDK params. +func (d *Driver) CreateSession(ctx context.Context, req payments.CheckoutSessionRequest) (payments.CheckoutSessionResult, error) { + quantity := req.Quantity + if quantity <= 0 { + quantity = 1 + } + + params := &stripesdk.CheckoutSessionParams{} + params.Context = ctx + params.SuccessURL = stripesdk.String(d.product.SuccessURL) + params.CancelURL = stripesdk.String(d.product.CancelURL) + params.Mode = stripesdk.String(string(stripesdk.CheckoutSessionModePayment)) + params.PaymentMethodTypes = stripesdk.StringSlice([]string{"card"}) + + params.LineItems = append(params.LineItems, &stripesdk.CheckoutSessionLineItemParams{ + Quantity: stripesdk.Int64(quantity), + PriceData: &stripesdk.CheckoutSessionLineItemPriceDataParams{ + Currency: stripesdk.String(d.product.Currency), + UnitAmount: stripesdk.Int64(d.product.PriceCents), + ProductData: &stripesdk.CheckoutSessionLineItemPriceDataProductDataParams{ + Name: stripesdk.String(d.product.Name), + Description: stripesdk.String(d.product.Description), + }, + }, + }) + + session, err := d.sessions.New(params) + if err != nil { + return payments.CheckoutSessionResult{}, err + } + if session == nil { + return payments.CheckoutSessionResult{}, errors.New("stripe returned nil session") + } + + return payments.CheckoutSessionResult{ID: session.ID, URL: session.URL}, nil +} diff --git a/internal/stripe/client_test.go b/internal/payments/driver/stripe/checkout_test.go similarity index 70% rename from internal/stripe/client_test.go rename to internal/payments/driver/stripe/checkout_test.go index ebbede0..d5cfcdc 100644 --- a/internal/stripe/client_test.go +++ b/internal/payments/driver/stripe/checkout_test.go @@ -5,33 +5,35 @@ import ( "errors" "testing" + stripesdk "github.com/stripe/stripe-go/v78" + "github.com/rjNemo/payit/config" - stripeapi "github.com/stripe/stripe-go/v78" + "github.com/rjNemo/payit/internal/payments" ) type fakeSessionCreator struct { - lastParams *stripeapi.CheckoutSessionParams - result *stripeapi.CheckoutSession + lastParams *stripesdk.CheckoutSessionParams + result *stripesdk.CheckoutSession err error } -func (f *fakeSessionCreator) New(params *stripeapi.CheckoutSessionParams) (*stripeapi.CheckoutSession, error) { +func (f *fakeSessionCreator) New(params *stripesdk.CheckoutSessionParams) (*stripesdk.CheckoutSession, error) { f.lastParams = params return f.result, f.err } -func TestService_CreateSessionSuccess(t *testing.T) { +func TestDriver_CreateSessionSuccess(t *testing.T) { product := testProductConfig() fake := &fakeSessionCreator{ - result: &stripeapi.CheckoutSession{ + result: &stripesdk.CheckoutSession{ ID: "cs_test_123", URL: "https://stripe.test/checkout", }, } - svc := &Service{product: product, sessions: fake} + driver := &Driver{product: product, sessions: fake} - res, err := svc.CreateSession(context.Background(), CheckoutSessionRequest{}) + res, err := driver.CreateSession(context.Background(), payments.CheckoutSessionRequest{}) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -47,7 +49,7 @@ func TestService_CreateSessionSuccess(t *testing.T) { if params.Context == nil { t.Fatal("expected context to propagate") } - if params.Mode == nil || *params.Mode != string(stripeapi.CheckoutSessionModePayment) { + if params.Mode == nil || *params.Mode != string(stripesdk.CheckoutSessionModePayment) { t.Fatalf("unexpected mode: %v", params.Mode) } if len(params.PaymentMethodTypes) != 1 || params.PaymentMethodTypes[0] == nil || *params.PaymentMethodTypes[0] != "card" { @@ -82,15 +84,15 @@ func TestService_CreateSessionSuccess(t *testing.T) { } } -func TestService_CreateSessionWithCustomQuantity(t *testing.T) { +func TestDriver_CreateSessionWithCustomQuantity(t *testing.T) { product := testProductConfig() fake := &fakeSessionCreator{ - result: &stripeapi.CheckoutSession{}, + result: &stripesdk.CheckoutSession{}, } - svc := &Service{product: product, sessions: fake} + driver := &Driver{product: product, sessions: fake} - _, err := svc.CreateSession(context.Background(), CheckoutSessionRequest{Quantity: 3}) + _, err := driver.CreateSession(context.Background(), payments.CheckoutSessionRequest{Quantity: 3}) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -106,25 +108,25 @@ func TestService_CreateSessionWithCustomQuantity(t *testing.T) { } } -func TestService_CreateSessionError(t *testing.T) { +func TestDriver_CreateSessionError(t *testing.T) { product := testProductConfig() fake := &fakeSessionCreator{err: errors.New("boom")} - svc := &Service{product: product, sessions: fake} + driver := &Driver{product: product, sessions: fake} - _, err := svc.CreateSession(context.Background(), CheckoutSessionRequest{}) + _, err := driver.CreateSession(context.Background(), payments.CheckoutSessionRequest{}) if err == nil { t.Fatal("expected error") } } -func TestService_CreateSessionNilSession(t *testing.T) { +func TestDriver_CreateSessionNilSession(t *testing.T) { product := testProductConfig() fake := &fakeSessionCreator{} - svc := &Service{product: product, sessions: fake} + driver := &Driver{product: product, sessions: fake} - _, err := svc.CreateSession(context.Background(), CheckoutSessionRequest{}) + _, err := driver.CreateSession(context.Background(), payments.CheckoutSessionRequest{}) if err == nil { t.Fatal("expected error for nil session") } diff --git a/internal/payments/service/checkout.go b/internal/payments/service/checkout.go new file mode 100644 index 0000000..ba7d628 --- /dev/null +++ b/internal/payments/service/checkout.go @@ -0,0 +1,31 @@ +package service + +import ( + "context" + + "github.com/rjNemo/payit/internal/payments" +) + +// CheckoutDriver represents a payment provider capable of creating checkout sessions. +type CheckoutDriver interface { + CreateSession(ctx context.Context, req payments.CheckoutSessionRequest) (payments.CheckoutSessionResult, error) +} + +// CheckoutService contains provider-agnostic business rules for initiating checkout flows. +type CheckoutService struct { + driver CheckoutDriver +} + +// NewCheckoutService wires the given driver into a reusable checkout service. +func NewCheckoutService(driver CheckoutDriver) *CheckoutService { + return &CheckoutService{driver: driver} +} + +// CreateSession applies domain defaults before delegating to the configured driver. +func (s *CheckoutService) CreateSession(ctx context.Context, req payments.CheckoutSessionRequest) (payments.CheckoutSessionResult, error) { + if req.Quantity <= 0 { + req.Quantity = 1 + } + + return s.driver.CreateSession(ctx, req) +} diff --git a/internal/payments/service/checkout_test.go b/internal/payments/service/checkout_test.go new file mode 100644 index 0000000..70bf30b --- /dev/null +++ b/internal/payments/service/checkout_test.go @@ -0,0 +1,61 @@ +package service + +import ( + "context" + "errors" + "testing" + + "github.com/rjNemo/payit/internal/payments" +) + +type fakeDriver struct { + lastReq payments.CheckoutSessionRequest + result payments.CheckoutSessionResult + err error +} + +func (f *fakeDriver) CreateSession(ctx context.Context, req payments.CheckoutSessionRequest) (payments.CheckoutSessionResult, error) { + f.lastReq = req + if f.err != nil { + return payments.CheckoutSessionResult{}, f.err + } + return f.result, nil +} + +func TestCheckoutService_DefaultQuantity(t *testing.T) { + drv := &fakeDriver{} + svc := NewCheckoutService(drv) + + _, err := svc.CreateSession(context.Background(), payments.CheckoutSessionRequest{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if drv.lastReq.Quantity != 1 { + t.Fatalf("expected default quantity 1, got %d", drv.lastReq.Quantity) + } +} + +func TestCheckoutService_PreservesQuantity(t *testing.T) { + drv := &fakeDriver{} + svc := NewCheckoutService(drv) + + _, err := svc.CreateSession(context.Background(), payments.CheckoutSessionRequest{Quantity: 5}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if drv.lastReq.Quantity != 5 { + t.Fatalf("expected quantity 5, got %d", drv.lastReq.Quantity) + } +} + +func TestCheckoutService_PropagatesError(t *testing.T) { + drv := &fakeDriver{err: errors.New("driver failed")} + svc := NewCheckoutService(drv) + + _, err := svc.CreateSession(context.Background(), payments.CheckoutSessionRequest{Quantity: 2}) + if err == nil { + t.Fatal("expected error from driver") + } +} diff --git a/internal/stripe/types.go b/internal/payments/types.go similarity index 95% rename from internal/stripe/types.go rename to internal/payments/types.go index 411d072..78a4054 100644 --- a/internal/stripe/types.go +++ b/internal/payments/types.go @@ -1,4 +1,4 @@ -package stripe +package payments // CheckoutSessionRequest captures optional inputs for creating a checkout session. type CheckoutSessionRequest struct { diff --git a/internal/stripe/client.go b/internal/stripe/client.go deleted file mode 100644 index bea290d..0000000 --- a/internal/stripe/client.go +++ /dev/null @@ -1,67 +0,0 @@ -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/web/handlers.go b/internal/web/handlers.go index 8d30b9d..04d2507 100644 --- a/internal/web/handlers.go +++ b/internal/web/handlers.go @@ -6,17 +6,16 @@ import ( "io" "net/http" - stripe "github.com/rjNemo/payit/internal/stripe" + "github.com/rjNemo/payit/internal/payments" ) func (h *Handler) createCheckoutSession(w http.ResponseWriter, r *http.Request) { - var req stripe.CheckoutSessionRequest + var req payments.CheckoutSessionRequest if r.Body != nil { defer func(body io.ReadCloser) { _ = body.Close() }(r.Body) - dec := json.NewDecoder(r.Body) dec.DisallowUnknownFields() diff --git a/internal/web/handlers_test.go b/internal/web/handlers_test.go index e05a73a..37df129 100644 --- a/internal/web/handlers_test.go +++ b/internal/web/handlers_test.go @@ -9,19 +9,19 @@ import ( "net/http/httptest" "testing" - stripe "github.com/rjNemo/payit/internal/stripe" + "github.com/rjNemo/payit/internal/payments" ) type fakeCheckoutService struct { - result stripe.CheckoutSessionResult + result payments.CheckoutSessionResult err error - req stripe.CheckoutSessionRequest + req payments.CheckoutSessionRequest } -func (f *fakeCheckoutService) CreateSession(ctx context.Context, req stripe.CheckoutSessionRequest) (stripe.CheckoutSessionResult, error) { +func (f *fakeCheckoutService) CreateSession(ctx context.Context, req payments.CheckoutSessionRequest) (payments.CheckoutSessionResult, error) { f.req = req if f.err != nil { - return stripe.CheckoutSessionResult{}, f.err + return payments.CheckoutSessionResult{}, f.err } return f.result, nil } @@ -29,7 +29,7 @@ func (f *fakeCheckoutService) CreateSession(ctx context.Context, req stripe.Chec func TestCreateCheckoutSessionSuccess(t *testing.T) { handler := &Handler{ checkout: &fakeCheckoutService{ - result: stripe.CheckoutSessionResult{ID: "cs_test_1", URL: "https://stripe.test/checkout"}, + result: payments.CheckoutSessionResult{ID: "cs_test_1", URL: "https://stripe.test/checkout"}, }, } @@ -47,7 +47,7 @@ func TestCreateCheckoutSessionSuccess(t *testing.T) { t.Fatalf("expected json content type, got %s", ct) } - var payload stripe.CheckoutSessionResult + var payload payments.CheckoutSessionResult if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { t.Fatalf("expected valid json response: %v", err) } @@ -63,7 +63,7 @@ func TestCreateCheckoutSessionSuccess(t *testing.T) { func TestCreateCheckoutSessionDefaultsQuantity(t *testing.T) { fakeSvc := &fakeCheckoutService{ - result: stripe.CheckoutSessionResult{ID: "cs_test_1"}, + result: payments.CheckoutSessionResult{ID: "cs_test_1"}, } handler := &Handler{checkout: fakeSvc} diff --git a/internal/web/page.go b/internal/web/page.go new file mode 100644 index 0000000..9438b95 --- /dev/null +++ b/internal/web/page.go @@ -0,0 +1,30 @@ +package web + +import ( + "fmt" + "net/http" + "strings" +) + +type checkoutPageData struct { + ProductName string + ProductDescription string + PriceDisplay string + Currency string +} + +func (h *Handler) renderCheckoutPage(w http.ResponseWriter, r *http.Request) { + price := float64(h.cfg.Product.PriceCents) / 100 + data := checkoutPageData{ + ProductName: h.cfg.Product.Name, + ProductDescription: h.cfg.Product.Description, + PriceDisplay: fmt.Sprintf("$%.2f", price), + Currency: strings.ToUpper(h.cfg.Product.Currency), + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := h.page.ExecuteTemplate(w, "index.html", data); err != nil { + http.Error(w, "failed to render page", http.StatusInternalServerError) + return + } +} diff --git a/internal/web/server.go b/internal/web/server.go index 90545d3..be7b8b2 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -2,30 +2,45 @@ package web import ( "context" + "fmt" + "html/template" + "io/fs" "net/http" - stripe "github.com/rjNemo/payit/internal/stripe" - "github.com/rjNemo/payit/config" + "github.com/rjNemo/payit/internal/payments" + "github.com/rjNemo/payit/internal/payments/driver/stripe" + "github.com/rjNemo/payit/internal/payments/service" + webassets "github.com/rjNemo/payit/web" ) type checkoutService interface { - CreateSession(context.Context, stripe.CheckoutSessionRequest) (stripe.CheckoutSessionResult, error) + CreateSession(context.Context, payments.CheckoutSessionRequest) (payments.CheckoutSessionResult, error) } // Handler aggregates dependencies required by HTTP handlers. type Handler struct { cfg config.Config checkout checkoutService + page *template.Template } // NewServer constructs the root HTTP handler, wiring Stripe-backed endpoints as they are implemented. func NewServer(cfg config.Config) http.Handler { - checkoutSvc := stripe.NewService(cfg.StripeSecretKey, cfg.Product) - h := &Handler{cfg: cfg, checkout: checkoutSvc} + driver := stripe.NewDriver(cfg.StripeSecretKey, cfg.Product) + checkoutSvc := service.NewCheckoutService(driver) + tmpl := template.Must(template.ParseFS(webassets.Assets, "templates/index.html")) + staticFS, err := fs.Sub(webassets.Assets, "static") + if err != nil { + panic(fmt.Errorf("failed to load static assets: %w", err)) + } + + h := &Handler{cfg: cfg, checkout: checkoutSvc, page: tmpl} mux := http.NewServeMux() mux.HandleFunc("POST /api/checkout", h.createCheckoutSession) + mux.Handle("GET /", http.HandlerFunc(h.renderCheckoutPage)) + mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS)))) return mux } diff --git a/web/embed.go b/web/embed.go new file mode 100644 index 0000000..030c663 --- /dev/null +++ b/web/embed.go @@ -0,0 +1,8 @@ +package webassets + +import "embed" + +// Assets bundles HTML templates and static files for the checkout UI. +// +//go:embed templates/*.html static/* +var Assets embed.FS diff --git a/web/static/app.js b/web/static/app.js new file mode 100644 index 0000000..37c23dd --- /dev/null +++ b/web/static/app.js @@ -0,0 +1,59 @@ +(() => { + const form = document.getElementById('checkout-form'); + const button = document.getElementById('checkout-button'); + const qtyInput = document.getElementById('quantity'); + const message = document.getElementById('message'); + + if (!form || !button || !qtyInput) { + return; + } + + const setMessage = (text, isError = true) => { + if (!message) { + return; + } + message.textContent = text; + message.style.color = isError ? '#dc2626' : '#16a34a'; + }; + + form.addEventListener('submit', async (event) => { + event.preventDefault(); + + const quantity = Number.parseInt(qtyInput.value, 10); + if (!Number.isFinite(quantity) || quantity <= 0) { + setMessage('Enter a quantity of at least 1.'); + qtyInput.focus(); + return; + } + + try { + button.disabled = true; + setMessage('Contacting Stripe…', false); + + const response = await fetch('/api/checkout', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ quantity }), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(errorText || 'Checkout request failed.'); + } + + const data = await response.json(); + if (!data || !data.url) { + throw new Error('Checkout response missing redirect URL.'); + } + + setMessage('Redirecting to Stripe…', false); + window.location.href = data.url; + } catch (err) { + console.error('Checkout failed', err); + setMessage('Unable to start checkout. Please try again.'); + button.disabled = false; + } + }); +})(); diff --git a/web/templates/index.html b/web/templates/index.html new file mode 100644 index 0000000..178a422 --- /dev/null +++ b/web/templates/index.html @@ -0,0 +1,102 @@ + + +
+ + +{{ .ProductDescription }}
+