feat: add checkout ui and driver-based payments

This commit is contained in:
Ruidy 2025-09-27 18:43:41 +02:00
parent 4e36eb9692
commit f0a6b128a0
No known key found for this signature in database
GPG key ID: 705C24D202990805
15 changed files with 416 additions and 104 deletions

1
.gitignore vendored
View file

@ -1 +1,2 @@
*cache
*env*

View file

@ -97,8 +97,10 @@ func applyEnvFile(path string) (retErr error) {
key := strings.TrimSpace(parts[0])
value := strings.TrimSpace(parts[1])
if _, exists := os.LookupEnv(key); !exists {
_ = os.Setenv(key, value)
}
}
if err := scanner.Err(); err != nil {
return err

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

View file

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

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

View 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")
}
}

View file

@ -1,4 +1,4 @@
package stripe
package payments
// CheckoutSessionRequest captures optional inputs for creating a checkout session.
type CheckoutSessionRequest struct {

View file

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

View file

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

View file

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

30
internal/web/page.go Normal file
View 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
}
}

View file

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

8
web/embed.go Normal file
View 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
View 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
View 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>