mirror of
https://github.com/rjNemo/payit
synced 2026-06-06 02:16:40 +00:00
feat: add checkout ui and driver-based payments
This commit is contained in:
parent
4e36eb9692
commit
f0a6b128a0
15 changed files with 416 additions and 104 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1 +1,2 @@
|
||||||
*cache
|
*cache
|
||||||
|
*env*
|
||||||
|
|
|
||||||
|
|
@ -97,7 +97,9 @@ func applyEnvFile(path string) (retErr error) {
|
||||||
|
|
||||||
key := strings.TrimSpace(parts[0])
|
key := strings.TrimSpace(parts[0])
|
||||||
value := strings.TrimSpace(parts[1])
|
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 {
|
if err := scanner.Err(); err != nil {
|
||||||
|
|
|
||||||
69
internal/payments/driver/stripe/checkout.go
Normal file
69
internal/payments/driver/stripe/checkout.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -5,33 +5,35 @@ import (
|
||||||
"errors"
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
stripesdk "github.com/stripe/stripe-go/v78"
|
||||||
|
|
||||||
"github.com/rjNemo/payit/config"
|
"github.com/rjNemo/payit/config"
|
||||||
stripeapi "github.com/stripe/stripe-go/v78"
|
"github.com/rjNemo/payit/internal/payments"
|
||||||
)
|
)
|
||||||
|
|
||||||
type fakeSessionCreator struct {
|
type fakeSessionCreator struct {
|
||||||
lastParams *stripeapi.CheckoutSessionParams
|
lastParams *stripesdk.CheckoutSessionParams
|
||||||
result *stripeapi.CheckoutSession
|
result *stripesdk.CheckoutSession
|
||||||
err error
|
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
|
f.lastParams = params
|
||||||
return f.result, f.err
|
return f.result, f.err
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestService_CreateSessionSuccess(t *testing.T) {
|
func TestDriver_CreateSessionSuccess(t *testing.T) {
|
||||||
product := testProductConfig()
|
product := testProductConfig()
|
||||||
fake := &fakeSessionCreator{
|
fake := &fakeSessionCreator{
|
||||||
result: &stripeapi.CheckoutSession{
|
result: &stripesdk.CheckoutSession{
|
||||||
ID: "cs_test_123",
|
ID: "cs_test_123",
|
||||||
URL: "https://stripe.test/checkout",
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -47,7 +49,7 @@ func TestService_CreateSessionSuccess(t *testing.T) {
|
||||||
if params.Context == nil {
|
if params.Context == nil {
|
||||||
t.Fatal("expected context to propagate")
|
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)
|
t.Fatalf("unexpected mode: %v", params.Mode)
|
||||||
}
|
}
|
||||||
if len(params.PaymentMethodTypes) != 1 || params.PaymentMethodTypes[0] == nil || *params.PaymentMethodTypes[0] != "card" {
|
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()
|
product := testProductConfig()
|
||||||
fake := &fakeSessionCreator{
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
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()
|
product := testProductConfig()
|
||||||
fake := &fakeSessionCreator{err: errors.New("boom")}
|
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 {
|
if err == nil {
|
||||||
t.Fatal("expected error")
|
t.Fatal("expected error")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestService_CreateSessionNilSession(t *testing.T) {
|
func TestDriver_CreateSessionNilSession(t *testing.T) {
|
||||||
product := testProductConfig()
|
product := testProductConfig()
|
||||||
fake := &fakeSessionCreator{}
|
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 {
|
if err == nil {
|
||||||
t.Fatal("expected error for nil session")
|
t.Fatal("expected error for nil session")
|
||||||
}
|
}
|
||||||
31
internal/payments/service/checkout.go
Normal file
31
internal/payments/service/checkout.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
61
internal/payments/service/checkout_test.go
Normal file
61
internal/payments/service/checkout_test.go
Normal file
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package stripe
|
package payments
|
||||||
|
|
||||||
// CheckoutSessionRequest captures optional inputs for creating a checkout session.
|
// CheckoutSessionRequest captures optional inputs for creating a checkout session.
|
||||||
type CheckoutSessionRequest struct {
|
type CheckoutSessionRequest struct {
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -6,17 +6,16 @@ import (
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"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) {
|
func (h *Handler) createCheckoutSession(w http.ResponseWriter, r *http.Request) {
|
||||||
var req stripe.CheckoutSessionRequest
|
var req payments.CheckoutSessionRequest
|
||||||
|
|
||||||
if r.Body != nil {
|
if r.Body != nil {
|
||||||
defer func(body io.ReadCloser) {
|
defer func(body io.ReadCloser) {
|
||||||
_ = body.Close()
|
_ = body.Close()
|
||||||
}(r.Body)
|
}(r.Body)
|
||||||
|
|
||||||
dec := json.NewDecoder(r.Body)
|
dec := json.NewDecoder(r.Body)
|
||||||
dec.DisallowUnknownFields()
|
dec.DisallowUnknownFields()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,19 +9,19 @@ import (
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
stripe "github.com/rjNemo/payit/internal/stripe"
|
"github.com/rjNemo/payit/internal/payments"
|
||||||
)
|
)
|
||||||
|
|
||||||
type fakeCheckoutService struct {
|
type fakeCheckoutService struct {
|
||||||
result stripe.CheckoutSessionResult
|
result payments.CheckoutSessionResult
|
||||||
err error
|
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
|
f.req = req
|
||||||
if f.err != nil {
|
if f.err != nil {
|
||||||
return stripe.CheckoutSessionResult{}, f.err
|
return payments.CheckoutSessionResult{}, f.err
|
||||||
}
|
}
|
||||||
return f.result, nil
|
return f.result, nil
|
||||||
}
|
}
|
||||||
|
|
@ -29,7 +29,7 @@ func (f *fakeCheckoutService) CreateSession(ctx context.Context, req stripe.Chec
|
||||||
func TestCreateCheckoutSessionSuccess(t *testing.T) {
|
func TestCreateCheckoutSessionSuccess(t *testing.T) {
|
||||||
handler := &Handler{
|
handler := &Handler{
|
||||||
checkout: &fakeCheckoutService{
|
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)
|
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 {
|
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
||||||
t.Fatalf("expected valid json response: %v", err)
|
t.Fatalf("expected valid json response: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -63,7 +63,7 @@ func TestCreateCheckoutSessionSuccess(t *testing.T) {
|
||||||
|
|
||||||
func TestCreateCheckoutSessionDefaultsQuantity(t *testing.T) {
|
func TestCreateCheckoutSessionDefaultsQuantity(t *testing.T) {
|
||||||
fakeSvc := &fakeCheckoutService{
|
fakeSvc := &fakeCheckoutService{
|
||||||
result: stripe.CheckoutSessionResult{ID: "cs_test_1"},
|
result: payments.CheckoutSessionResult{ID: "cs_test_1"},
|
||||||
}
|
}
|
||||||
handler := &Handler{checkout: fakeSvc}
|
handler := &Handler{checkout: fakeSvc}
|
||||||
|
|
||||||
|
|
|
||||||
30
internal/web/page.go
Normal file
30
internal/web/page.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,30 +2,45 @@ package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"io/fs"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
stripe "github.com/rjNemo/payit/internal/stripe"
|
|
||||||
|
|
||||||
"github.com/rjNemo/payit/config"
|
"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 {
|
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.
|
// Handler aggregates dependencies required by HTTP handlers.
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
cfg config.Config
|
cfg config.Config
|
||||||
checkout checkoutService
|
checkout checkoutService
|
||||||
|
page *template.Template
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewServer constructs the root HTTP handler, wiring Stripe-backed endpoints as they are implemented.
|
// 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 {
|
||||||
checkoutSvc := stripe.NewService(cfg.StripeSecretKey, cfg.Product)
|
driver := stripe.NewDriver(cfg.StripeSecretKey, cfg.Product)
|
||||||
h := &Handler{cfg: cfg, checkout: checkoutSvc}
|
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 := http.NewServeMux()
|
||||||
mux.HandleFunc("POST /api/checkout", h.createCheckoutSession)
|
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
|
return mux
|
||||||
}
|
}
|
||||||
|
|
|
||||||
8
web/embed.go
Normal file
8
web/embed.go
Normal file
|
|
@ -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
|
||||||
59
web/static/app.js
Normal file
59
web/static/app.js
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
102
web/templates/index.html
Normal file
102
web/templates/index.html
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>PayIt Checkout</title>
|
||||||
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap" />
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: light dark;
|
||||||
|
font-family: "Inter", system-ui, -apple-system, sans-serif;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: radial-gradient(circle at top, #fdfbfb, #ebedee);
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
border-radius: 18px;
|
||||||
|
box-shadow: 0 20px 45px rgba(15, 23, 42, 0.12);
|
||||||
|
padding: 2.5rem;
|
||||||
|
max-width: 420px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
margin-top: 0;
|
||||||
|
font-size: 1.75rem;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
margin: 0.5rem 0 1.5rem;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
.price {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #2563eb;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
input[type="number"] {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid #cbd5f5;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
background: linear-gradient(135deg, #2563eb, #7c3aed);
|
||||||
|
border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.9rem 1.2rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
button:hover:not([disabled]) {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 12px 30px rgba(37, 99, 235, 0.25);
|
||||||
|
}
|
||||||
|
button[disabled] {
|
||||||
|
opacity: 0.65;
|
||||||
|
cursor: wait;
|
||||||
|
}
|
||||||
|
#message {
|
||||||
|
min-height: 1.5rem;
|
||||||
|
color: #dc2626;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="card">
|
||||||
|
<h1>{{ .ProductName }}</h1>
|
||||||
|
<p>{{ .ProductDescription }}</p>
|
||||||
|
<div class="price">{{ .PriceDisplay }} <span class="currency">{{ .Currency }}</span></div>
|
||||||
|
<form id="checkout-form">
|
||||||
|
<label for="quantity">Quantity</label>
|
||||||
|
<input id="quantity" name="quantity" type="number" value="1" min="1" />
|
||||||
|
<button id="checkout-button" type="submit">Buy now</button>
|
||||||
|
</form>
|
||||||
|
<div id="message" role="status" aria-live="polite"></div>
|
||||||
|
</main>
|
||||||
|
<script src="/static/app.js" defer></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Loading…
Reference in a new issue